Story: Add proper curve support to ores.marketdata
Table of Contents
- 1. Goal
- 2. Status
- 3. Acceptance
- 4. Tasks
- 5. Decisions
- 5.1. The shape is a first-class cold declaration
- 5.2. The axes are the codec's coordinate fields, not a second vocabulary
- 5.3. The build declares the shape, and the shape is validated before it is written
- 5.4. The build checks its terms against the reference data, the import records what the file says
- 5.5. A strike label is a typed code on the codec, not a reference-data list
- 5.6. A slice is the object's instants against the shape's ladder
- 5.7. An instant carries the points it states, and the grid is the shape's product
- 5.8. The snapshot shares the nodes and keeps the as-of value
- 5.9. Provenance rides on the snapshot, when asked
- 5.10. A coordinate is typed in the service and is not a column
- 5.11. A derived point names its recipe; the evolution read carries no provenance
- 5.12. A manual point owns its coordinate until it is cleared
- 5.13. No curve_builds table; the table the journeys earn is a publication record
- 6. Out of scope
- 7. Promoted from capture
- 7.1. Goal
- 7.2. Status
- 7.3. What exists today
- 7.4. Deeper analysis, 2026-10-06
- 7.4.1. What a curve is made of
- 7.4.2. The correction that matters: lineage is most of the build
- 7.4.3. The second correction: the point identity is the URI
- 7.4.4. How each part maps onto the existing infrastructure
- 7.4.5. The modelling decision
- 7.4.6. Curves and tenors
- 7.4.7. The user journeys are the input that decides this
- 7.4.8. Defects found on the way, independent of the representation
- 7.4.9. Recommended next steps
- 7.5. Curve data model, 2026-10-06
- 7.6. The composite object is the requirement, 2026-10-06
- 7.6.1. Today's answer to the canonical question, and why it is not easy
- 7.6.2. Where is the shape? There is no shape table, and there must be
- 7.6.3. The shape is a first-class, cold declaration
- 7.6.4. The components: ATM, RR and BF are the surface's own axis
- 7.6.5. One series per surface, or one per component?
- 7.6.6. Manually keyed points
- 7.6.7. Precedence: a feed must not silently undo a manual point
- 7.6.8. The requirements the use case implies
- 7.6.9. What to add, in order
- 7.7. Journey to model reconciliation, 2026-10-07
- 7.7.1. What the tree has moved since the earlier passes
- 7.7.2. Every journey, what serves it, and the gap
- 7.7.3. The five join hops, confirmed and corrected
- 7.7.4. Open questions, answered
- 7.7.5. The smallest representation that satisfies the acceptance
- 7.7.6. The first production consumer
- 7.7.7. Decisions taken here
- 7.7.8. What this pass could not determine
- 7.8. Journey catalogue, signed off 2026-10-06
- 7.9. Acceptance
- 7.10. Tasks
- 7.11. Decisions
- 7.12. Out of scope
- 7.13. Open questions for the deeper analysis
- 7.14. Staleness, 2026-10-07
- 7.15. References
This page documents a story in Sprint 27. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
1. Goal
Give ores.marketdata a first-class representation of a curve, so a bootstrapped curve is an entity with identity, provenance and a consumer rather than a derived series nothing reads back.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent sprint | Sprint 27 |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. The publication record waits for the publish operation. |
| Last touched | 2026-10-10 |
3. Acceptance
4. Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Add prototypes for all market data journeys | DONE | 2026-10-06 | 2026-10-06 | One prototype per market data journey, the oresmd URL editor every screen calls, and the plotting for term structures and surfaces. |
| Check the curve model against the journeys and start the representation | DONE | 2026-10-07 | 2026-10-07 | Settle the shape the journeys demand against the tables that exist, give a point its provenance, and start the representation. |
| Let an operator key and clear a curve point by hand | DONE | 2026-10-07 | 2026-10-07 | Add the write and clear path, so an operator over-keys a coordinate and withdraws it, with the actor and reason recorded. |
| Show how stale each point of a term structure is | DONE | 2026-10-08 | 2026-10-08 | Every point carries its age, and the view shows it, so a composite of several market states is never mistaken for one. |
| Resolve a composite object from its typed identity | DONE | 2026-10-08 | 2026-10-08 | A caller names an object by its typed identity and the server resolves it to a series, so no view builds an oresmd URI by hand and none matches on a string. |
| Give a composite object its shape tables | DONE | 2026-10-08 | 2026-10-08 | Record the axes a composite object varies over and their ordered values, written from the recipe's pillars by a build, and refuse a point whose coordinate is not a declared axis value. |
| Refuse a point its shape does not declare | DONE | 2026-10-08 | 2026-10-09 | Check a point's coordinate against the shape its series declares, at write time, so the shape and the points cannot drift; and write a shape from the declaration an import carries. |
| Let an import declare the shape it carries | DONE | 2026-10-09 | 2026-10-09 | Give an observed or imported structure a shape of its own, so the point check has something to enforce on it, and settle where the shape writer lives once the import can reach it. |
| Type the axis values a composite object varies over | DONE | 2026-10-09 | 2026-10-09 | Give the axis value vocabulary its own typed codes, so a strike label and a quoting convention are canonical and the components of a surface can be enumerated, instead of every component being a free-text axis value. |
| Read one slice of a composite object over time | DONE | 2026-10-09 | 2026-10-09 | Return one component of a composite object, such as the ATM ladder of a volatility surface, as a term structure over time, from the object definition the shape declares. |
| Read a composite object's aligned evolution | DONE | 2026-10-09 | 2026-10-09 | Return a composite object over a range or a set of instants as instants by nodes, with the coordinate labels and numeric values, and the added, dropped and persistent nodes against the declared shape, replacing the fixed five-bucket window. |
| Reshape the single-instant read to the object definition | DONE | 2026-10-10 | 2026-10-10 | Bring the single-instant read onto the same object definition as the slice read and the evolution read, so the three cannot disagree about what a composite object is. |
| Give every point a provenance annex | DONE | 2026-10-10 | 2026-10-10 | Record the source kind of every point, quoted, derived or manual, and for a manual point the actor, the time and the reason, in one cold annex keyed by the point's natural key. |
| State what a later automatic write does to a manual point | DONE | 2026-10-10 | 2026-10-10 | Decide and record what an automatic write does to a point an operator keyed by hand, and cover the rule with a test. |
| Decide the curve_builds table | DONE | 2026-10-10 | 2026-10-10 | Decide whether a curve_builds table is needed, from the agreed journeys, once the shape, the reads and the provenance are settled. |
| Audit the story acceptance against the tree | DONE | 2026-10-10 | 2026-10-10 | Check each bullet of the story's acceptance list against the tree, record the evidence, and close the story or name what is left. |
| Expose the recipe on a point's provenance | DONE | 2026-10-10 | 2026-10-10 | Return the derivation config, its version and the source as-of beside a derived point's provenance, so a reader sees which recipe built it. |
| Decide how a node's coordinate is queryable | DONE | 2026-10-10 | 2026-10-10 | Decide whether a node's coordinate tuple must be a SQL-queryable column set, or whether the typed oresmd URI parsed in the service tier meets the acceptance. |
5. Decisions
5.1. The shape is a first-class cold declaration
Chosen 2026-10-08, with the shape tables task. The object's axes and their ordered
values are stored, in two current-state tables that join the series by series_id.
Without them the shape is an inference from the points, so a tenor the feed dropped is
indistinguishable from a tenor the structure does not have, a hole cannot be drawn, and
the components of a surface cannot be enumerated without scanning history. The tables
are cold: tens of rows per object, read when an axis is drawn or a build is validated,
never on the point path.
5.2. The axes are the codec's coordinate fields, not a second vocabulary
Chosen 2026-10-08, with the shape tables task. The writer takes the axes from the instrument type's schema row, so the axes a series declares are exactly the coordinate fields the codec already reads from its points. A second list of axis names would be a vocabulary that could disagree with the grammar that writes the URIs.
5.3. The build declares the shape, and the shape is validated before it is written
Chosen 2026-10-08, with the shape tables task. A build writes the shape from the points
it published, so the shape and the points cannot drift at the moment of publication, and
an observed or imported structure will write it from what it declares. Every value of a
term-kind axis is checked against refdata_tenors before any row is written, so a
refused shape leaves nothing behind. The rule that refuses a point whose coordinate the
shape does not declare is its own task, because it needs a hook on the generated
observation write path.
5.4. The build checks its terms against the reference data, the import records what the file says
Chosen 2026-10-09, with the import task. The shape writer moved down into
ores.marketdata.core so the import can call it, and declare now takes the
check as an argument. A build passes against_reference_tenors, because its
pillars come from the tenant's own curve configuration and a pillar the reference
table does not hold is a mistake. An import passes as_stated, because the terms
it records are the vendor's, and the ORE example corpus writes 13Y and 1Y6M
where the seed holds 46 codes and neither of those. A single check enforced on
both paths would refuse valid market data.
The import declares for every series a file carries, not only the ones it creates. One series is named by several corpus files with different grids, so freezing its shape at the first file would refuse every later file. A later file appends the terms it states after the ones already stored.
5.5. A strike label is a typed code on the codec, not a reference-data list
Chosen 2026-10-09, with the axis value task. A strike label is stored as a
canonical code. datum::strike_label holds the label's form and its number,
and its parser fixes the suffix case and drops a redundant sign or a fraction
of zeros, so 25rr, +25RR and 25.0RR are one value and one component.
The grammar sits beside the codec's other ORE vocabularies rather than in a
reference-data list, for three reasons. The delta number is open, so a closed
list refuses valid market data, which is the defect the import path already met
with tenors. The vocabulary belongs to the grammar ORE's parser runs, which is
what codes_of already holds for every other checked field. And the journey
that would own such a list, journey 2, is deferred to its own story.
The forms are exactly the ones ORE's FXOptionQuote admits, so the codec
refuses ATMF and 25D, which ORE's strike grammar reads and its FX option
quote then throws on. correlation shares the field, so its strike is checked
by the same grammar. A per-surface quoting convention, the ATM type and the
delta type, stays with journey 2.
5.6. A slice is the object's instants against the shape's ladder
Chosen 2026-10-09, with the slice read. A component is returned as a term
structure at each instant the object holds in a range, and the read is one
statement: the instants come from the data, and the per-instant reduction is the
same LATERAL and DISTINCT ON the bucket read already runs over the same
index.
Two choices follow from what a term structure is. The instants are the object's and not the component's, so an instant at which only another component moved still appears, with this one carried forward; otherwise every instant would hold a different set of nodes and none would be a term structure. The ladder is the shape's and not the points', so a node the object declares and no point fills is a hole, which is what the shape tables exist to make visible.
The read serves an object of one or two axes and refuses more. A term structure has one ladder, so an object of three axes, such as a swaption cube, has a slice that is a surface, and that is the evolution read's work.
The component is selected in C++ through the codec rather than in SQL, because a component is a coordinate inside the point's URI and a text match on it would depend on how the codec encodes it.
The read takes the snapshot family's marketdata::curve_snapshots:read
permission. A slice reads the same data as the snapshot it belongs to, so a
second permission would split one grant across two subjects.
5.7. An instant carries the points it states, and the grid is the shape's product
Chosen 2026-10-09, with the aligned evolution read, and it changes what the slice read returns.
The instants are the points the object states, not the points it held by then. Under an as-of reconstruction a node that was once quoted is carried for ever, so no node is ever dropped and an added-and-dropped report has nothing to say. The slice read shipped carrying the last known value forward and now shows the holes too, because the two reads must not disagree about what the object states. The as-of reads stay for the single-instant snapshot, and reshaping that read onto this definition is its own task.
The nodes are every combination of the declared axis values, in the order the
shape stores the axes and with the last axis varying fastest. That is what makes
a declared node distinguishable from a node the object does not have: the first
is a cell of the matrix, marked never_quoted when no instant states it, and the
second is not in the response at all.
A node's mark is the change it shows over the range: never_quoted,
persistent, added, dropped, or intermittent for what is left. It is a
summary of the range and not of one step, so a node dropped and then added again
is added; a caller that wants the step-by-step change reads two consecutive
rows, which the matrix carries.
The fixed five-bucket window is removed with the read that replaces it. The
repository's own read_as_of_buckets stays, because the as-of-bucket capture
names it as the reference implementation of the pattern that capture exists to
generalize.
5.8. The snapshot shares the nodes and keeps the as-of value
Chosen 2026-10-10, with the single-instant task. The snapshot, the slice and the
evolution take the object by its typed identity, and the snapshot and the evolution
take their nodes from one series_grid, so they cannot disagree about which nodes
the object has or where a point sits.
The snapshot keeps the as-of read. It carries the latest value of a node forward from before the instant, because the snapshot answers what the market was at that moment and the staleness figures measure how old each carried value is. The evolution states only what an instant states. So the two agree on the nodes and on every value the evolution holds, and the snapshot may hold a value where the evolution holds a hole. That difference is the meaning of the two reads.
An object that declares no axis is refused by all three reads.
5.9. Provenance rides on the snapshot, when asked
Chosen 2026-10-10, with the provenance annex task. The annex already holds the
source kind, the actor, the reason and the time, so the gap was a read. The
snapshot returns the value, so it returns the provenance beside it, and only when
the request sets include_provenance. The annex is read for the points the
snapshot placed and for no others, so the point path pays nothing when nobody
asks. The evolution read does not carry provenance, because a per-instant annex
read over a range is a larger decision than this task.
5.10. A coordinate is typed in the service and is not a column
Chosen 2026-10-10, with the coordinate task. The acceptance asks for a coordinate that is queryable and joinable. It is both, through the typed reads: the codec parses it, the write checks it against the declared shape, the shared grid places the point, and the slice and evolution reads select by it. It is not a SQL column, because the component made the URI the only identity, and no journey asks for points across series by coordinate. The reopening event is such a journey. The form then is an index on a derived projection of the URI's parts, not a column and not a table.
The series identity is different. The "joinable identity" story projected a series'
identity into one column per codec field on market_series, a cold table of few rows.
This decision concerns the point, a hot hypertable row, and it leaves that story intact.
Rejected: coordinate columns on the observation. They give a row two identities that can disagree, and they put one facet's coordinate on a generic table.
5.11. A derived point names its recipe; the evolution read carries no provenance
Chosen 2026-10-10, with the recipe task, after the acceptance audit found that a point's provenance named the source kind, the actor, the reason and the time, but not the recipe. The annex already holds the derivation config, its version and the source as-of, so the snapshot returns them beside the source kind. A point no recipe built carries an empty config, version zero and the epoch.
The evolution read stays without provenance. It would return one annex row for each state of each node over a range, and no journey step asks for that. The watch journey reads the lineage of one derived point when the person selects it. The snapshot serves the point that is live now. The lineage read by row id serves a point in the past, and it is marked partial in the journey because it is keyed by the annex row and not by the point. That gap belongs to a lineage read keyed by the observation, and it is not this decision.
Rejected: a provenance matrix on the evolution read. It repeats the cost the snapshot avoids by asking only when the caller asks, over every instant of the range.
5.12. A manual point owns its coordinate until it is cleared
Chosen 2026-10-10, with the manual precedence task. Two rules, one for each way an automatic write can reach a manual point.
An automatic write at a later instant leaves the manual row where it is. The as-of read prefers the manual point to any later value the feed wrote, so the snapshot shows the manual value until the point is cleared. This rule already held on the read side.
An automatic write at the very same instant would replace the manual row and leave
the annex naming a value the row no longer holds, so the point would read as manual
with the feed's number. The shared insert drops that write instead. The feed, the
import and the republish all write through that insert, so one rule covers all
three. Only write_manual_point writes a manual point, and it does not pass
through the guard. Clearing the point releases the coordinate.
The cost is one narrow read of the cold manual annex per series in each insert batch. It is a read on the feed's per-tick insert as well as on a bulk import, and a series nobody over-keyed returns no rows.
Rejected: locking the point in its provenance and skipping it in the ingest. It needs a second flag that the annex row already implies, and it would still need the same-instant case handled.
5.13. No curve_builds table; the table the journeys earn is a publication record
Chosen 2026-10-10, with the curve_builds task. The decision rests on the journeys, not on a preference.
A build is the points one derivation wrote at one reference date. The annex already
holds the three facts that name it: derivation_config_id, derivation_config_version
and source_as_of. The series and the reference date complete the key. A reader
groups on that key, and the group has no state of its own, so it needs no row. No
journey for bootstrap, fit check, hand adjustment or blueprint lists a build header
as missing.
Six journeys do list a missing object, and it is the same object each time: a published state. They are ask for a publish, decide a publish, publish the approved set, withdraw or replace, see who changed, and watch a curve evolve. A series has many publishes, withdrawals and replacements over time, and the trail shows each with an actor. A column on the series holds only the latest. A column on the points writes a cold fact to the hot hypertable. The state needs a small bitemporal record of its own.
That record is a publication, not a build. Its columns come from the journeys:
series, reference date, state (candidate, published, withdrawn, superseded),
published_by, published_at, superseded_by and request_id, the last because
the kind's detail is keyed by the approval request. It is not added now. No approval
kind, no publish operation and no consumer exists to test it, and a table with no
reader repeats the sprint 25 mistake. The story that adds the publish operation adds
the table with it.
Rejected: a curve_builds table with build_kind. Nothing reads the kind, and the
lineage annex already says whether a point was derived.
6. Out of scope
7. Promoted from capture
Captured 2026-09-27 in the product backlog; promoted preserving the UUID.
7.1. Goal
Make a curve a thing ores.marketdata owns. Today a curve exists only as a
side effect of one service: a `market_series` row stamped
derivation_kind'IR_CURVE_BOOTSTRAP'= with one market_observation per
pillar. Nothing gives it identity, nothing records what it was built from,
nothing tells a build apart from an observation, and nothing reads it back.
The outcome this story delivers: a curve can be created, identified, read and asked about its provenance, without the reader having to know that it is stored as a series.
7.2. Status
| Field | Value |
|---|---|
| State | BACKLOG |
| Parent sprint | Sprint 26 |
| Now | The journey documents and their prototypes are written, and the synthetic feeds now drive the new market data model, so the curve work can resume. |
| Waiting on | Nothing. The synthetic data generation story Implement synthetic data generation against the new market data model has merged and its feeds produce ticks into the model a curve reads. |
| Next | Check the data model against the journeys and start the representation. |
| Last touched | 2026-10-07 |
7.3. What exists today
The findings below came from a read-only investigation. Each is grounded in code; the paths are the evidence.
A curve is a derived series, and nothing else. A curve is created by
curve_republish_service::republish() in
projects/ores.marketdata/service/src/app/curve_republish_service.cpp (note
service/, not core/). It stamps the pre-minted output series
derivation_kind = "IR_CURVE_BOOTSTRAP" (:182), calls
quant::curve_bootstrap_engine::bootstrap() (:224), and writes one
market_observation per pillar (:296) plus observation_lineage rows
(:299). Derivation kinds are seeded in
projects/ores.sql/populate/refdata/refdata_derivation_kinds_populate.sql:
OBSERVED, IR_CURVE_BOOTSTRAP, CRM_DERIVATION.
There is no curve table, no curve entity and no curve type. The
component's seven tables are series, the asset-class junction, observations,
fixings, observation lineages, feed bindings and classification rules.
market_series has no curve column: marketdata_market_series_create.sql
carries only the key columns, series_subclass, and the derivation markers.
oresmd declares instrument_type::curve, and no line of code references
it.
A build is indistinguishable from an observation by shape. There is no
reference_date and no build_kind anywhere. The only markers are
derivation_kind on the series and a source string of the form
"ir_curve_bootstrap:<config id>" on the observation. The seeded example
marketdata_market_series_fomc_populate.sql shows the intended pairing: a
RATES=/=YIELD input grid and a YieldCurve=/=DISCOUNT output series for
the same qualifier.
The recipe lives in refdata, not marketdata.
ir_curve_bootstrap_config plus ir_curve_bootstrap_pillar hold the
interpolation method, day-count convention, split tenor, curve family role
(FUNDING or PROJECTION) and the ordered pillar list.
source_series_id and output_series_id are soft references into
marketdata's market_series — no foreign key, because the tables are in
different components. A database check requires
source_series_id <> output_series_id, so the first republish cannot
reclassify the raw feed's own series and overwrite live data.
Nothing consumes a curve. curve_health_checker has no production
callers at all. interpolate_discount_factor is called only from inside the
engine and its tests. The curve_snapshot reads
(marketdata.v1.curve-snapshot.get and .buckets) are thin wrappers over
read_as_of on one series' observations, so they work for any series and do
not know a curve when they read one. The bootstrapped output series is
write-only today.
A curve is reachable only by hand-crafted NATS. The two requests
republish_curve_request and compute_curve_request are registered at
application.cpp:202,208, with permissions
marketdata::curve_bootstrap:republish and :compute. There is no shell
verb, no HTTP route, no web client and no scheduler job that publishes on
either subject. The recipe table has no editing UI either: the model records
that its real editor, CurveBuilderWorkbench, lived in the removed desktop
client.
The design for doing this properly exists and was abandoned. Sprint 25's
adopt-oresmd-native-market-data-identity story proposed curve_build and
curve_point tables — a build is one observation of a curve entity, and its
points hang off it. That story was abandoned at sprint close on 2026-09-23
and nothing implements it. The classes survive in
ores.marketdata.puml under a "Target model" legend, marked <<new>>.
Three vocabularies are called a role, and two of them are called
curve_role. refdata's curve_role is DEPOSIT/FRA/SWAP/NONE and
classifies an instrument code, not a curve; oresmd's curve_role is
discount/projection/self_discounting; and
ir_curve_bootstrap_config.curve_family_role is FUNDING/PROJECTION, where
FUNDING does not mean discount. oresmd's curve_role is inert — parsed,
carried, re-serialised, and branched on nowhere. Its one behavioural
consumer in the whole tree is feeds_conflict() in
ores.synthetic/service/src/feed_controller.hpp.
Nothing crosses the ORE boundary in either direction. ORE builds a curve
in memory for the duration of a run from curveconfig.xml (which quote keys,
conventions, interpolation), market.txt (the values) and todaysmarket.xml
(which to build). None of that configuration is stored here: the only reader
of ORE's curveconfiguration model is a round-trip fidelity test. The
quote-to-curve wiring itself — ORE's Simple, AverageOIS, TenorBasis,
CrossCurrency, ZeroSpread segtypes — has no counterpart in this
repository. Going the other way, report_package_handler::prepare_package
describes itself as a stub that packs raw MsgPack rather than an ORE input
package, collect_results is a pass-through stub, and
ores.ore/core/src/xml/document_kind.cpp does not even classify a
curveconfig.xml dropped into an import.
Where ORE's own source is, and where curves live in it. The vendored copy
under external/ore/ ships examples, XSDs and prebuilt engine packages but
no engine source, so none of the above can be answered from it — and an
earlier pass at this investigation wrongly concluded the source was
unavailable. It is a separate checkout:
/home/marco/Vault/Development/ORE/Engine
The curve ground in that tree, for whoever picks this up:
| What | Where |
|---|---|
| The configuration classes | OREData/ored/configuration/curveconfig.hpp, curveconfigurations.hpp, and one *curveconfig.hpp per curve family |
| The curve specification | OREData/ored/marketdata/curvespec.hpp |
| Which curves a run builds | OREData/ored/marketdata/todaysmarket.hpp |
| The implementations | QuantExt/qle/termstructures/ (and QuantLib/ql/termstructures/yield/) |
| The user guide | Docs/UserGuide/parameterisation/curveconfig.tex, Docs/UserGuide/curve_configurations/yieldcurves.tex |
| The schema | xsd/curveconfig.xsd (the same file vendored here) |
Any deeper analysis of what a curve is, or of what a lossless ORE export would have to reproduce, should read that tree rather than the vendored copy.
7.4. Deeper analysis, 2026-10-06
A second read-only pass, at d02032a18c. It answers the open questions far
enough to choose a direction. It is a snapshot, not a description of the
system. Two facts the first investigation stated are corrected here, because
they change the shape of the answer.
7.4.1. What a curve is made of
Four things, and the tree already holds three of them under other names.
- Identity: which curve this is.
market_seriesis already the identity of a term structure; the oresmd URI is the name. The seeded example isoresmd://ir/USD?type=series&instrument=discount"e=rate&curve_id=USD-SOFR-FOMC((marketdata_market_series_fomc_populate.sql)). There is noinstrument_type::curvein code: the onlyinstrument_typeenum is ORE's own MarketDatum list (api/include/ores.marketdata.api/datum/ore_types.hpp), and the{fixing, curve, quote, vol}enum is a design-file fiction. A curve is named bycurve_idin the URI today, not by an instrument type. - Points: the pillar-to-value map. These are
market_observationrows. The point identity is theoresmd_uri; the observation table has nopoint_idcolumn (the design file draws one; the model does not). The pillar's tenor lives inside the URI. - Build: when the curve was built against which source, and by which recipe.
This is the part that is only half present.
market_seriescarriesderivation_kindandderivation_config_id/version;observation_lineagecarries, per point,derivation_config_id/version,source_as_ofandsource_series_ids. What is missing is a reference date and a build kind, and a build identity that groups the points of one build. - Recipe: how to build it. This lives in
ores.refdataasir_curve_bootstrap_config(family role, discount curve for a projection, interpolation, day count, tenor convention, split tenor) plusir_curve_bootstrap_pillarrows. Itsoutput_series_idand the pillars' tenor codes are soft references into other components.
7.4.2. The correction that matters: lineage is most of the build
The first investigation read the build as entirely absent. It is not:
observation_lineage is written by republish() beside every output
observation (curve_republish_service.cpp:365-366) and already answers "what
was this built from, by which recipe, and against what as-of". A build header
therefore does not have to invent provenance; it has to group the points of
one build and add the two facts lineage lacks, reference_date and
build_kind. That is a much smaller table than the sprint 25 design assumed.
7.4.3. The second correction: the point identity is the URI
The sprint 25 curve_point design draws a point_id column. The shipped
observation table has none; the point identity is the whole oresmd_uri. Any
curve design either reads the pillar identity out of the URI or adds a real
=point_id=/tenor column. The first is free; the second must be justified.
7.4.4. How each part maps onto the existing infrastructure
| Curve part | Existing home | State |
|---|---|---|
| Identity | market_series + oresmd URI curve_id |
Exists; the curve is not marked as a curve beyond derivation_kind and series_subclass |
| Points | market_observation (oresmd_uri, value, observation_datetime) |
Exists, bitemporal, versioned, RLS, evented |
| Point provenance | observation_lineage |
Exists per point |
| Build identity and reference date | none | Missing |
| Build kind (bootstrap / derived / imported) | none | Missing (IR_CURVE_BOOTSTRAP sits on the series, not the build) |
| Recipe | ir_curve_bootstrap_config + _pillar in ores.refdata |
Exists, cross-component soft refs |
| Recipe editing | refdata shell CRUD units | Exists for the recipe; no curve verb |
| Compute / publish | marketdata.v1.curve_bootstrap.compute / .republish |
Exists server-side; no sender anywhere |
| Read back | marketdata.v1.curve-snapshot.get / .buckets |
Exists; the handler checks no permission and no code is seeded |
| Consumer | none | Missing |
7.4.5. The modelling decision
Three shapes are available. The recommendation is the first, with the third held open until a journey proves it.
- Keep the series as the identity, add a build header, keep the points as
observations. A
curve_buildrow names the output series, thereference_date, thebuild_kind, the recipe (config id + version) and thesource_as_of; the points staymarket_observationrows whoseobservation_datetimeis the reference date and whoseoresmd_uriis the pillar. This reuses bitemporality, versioning, row-level security, eventing, shell units, the protocol and the TypeScript twin, and adds one small table. It matches whatrepublish()already writes, so no data has to move. - A
curve_pointtable besidecurve_build. The sprint 25 design. It buys an explicit point-to-build link and a place to hang point-level trustworthiness fields, and it pays for it with a second bitemporal, versioned, evented, shell-served table that duplicates the observation stack. Justified only if a consumer needs point sets that are independent of the series' own history, such as two builds compared point by point. - A
curveentity distinct frommarket_series. Rejected as the starting point: it duplicates the identity, versioning and history the series already owns. The series is the term structure; a curve is a series whose points are pillars.
The deciding input is what a consumer does with a curve, which is what the
journeys are for. The design deliberately stops before curve_point and lets
the journey work decide whether it is earned.
7.4.6. Curves and tenors
The relation is real but undeclared today, in three places.
- The recipe side.
ir_curve_bootstrap_pillar.start_tenor_codeand.end_tenor_codeare undeclared soft references torefdata_tenors: no foreign key, and the only database check validatescurve_role_code, not the tenors. The engine resolves a pillar byend_tenor_code -> tenors_by_code -> resolution(convention) -> anchor + offset [+ schedule steps] -> date(tenor_resolution.cpp:151-222), and the end tenor doubles as thepoint_idthe resolver reads the quote under (curve_republish_resolver.cpp:90-92). A typo in a tenor code is a runtime throw, not a save-time refusal. - The point side. The observation's
oresmd_uricarries the term coordinate (datum::term: period, date, fx_tenor or continuation), and the oresmdtermfield is the canonical datum-side name for a tenor. The two vocabularies join only by string at the pillar bridge; nothing checks that the URI's term is the pillar's tenor. - The gaps. Tenor resolution never rolls for a business day (the
conventions carry no calendar column), so a pillar that lands on a holiday
is not adjusted;
split_tenor_codeis stored but read by no consumer logic; anddataset_family_role=FUNDING/PROJECTIONis the one role vocabulary that is live, while refdata'scurve_roleis hard-coded in the engine and oresmd'scurve_roleis inert outside the synthetic feed's conflict check.
The recommendation is to make the pillar's tenor a checked reference to
refdata_tenors, and to carry that same tenor code on the curve's points so a
reader can group by tenor without parsing the URI. The business-day gap and
the split tenor are separate defects, not part of the representation.
7.4.7. The user journeys are the input that decides this
The tree has no market-data journey group and no journey that owns a curve;
the closest analogues are journey_keep_a_holiday_calendar_right (rules, then
a derived artefact consumers read) and journey_add_or_correct_a_currency
(entity with owned child panels and history). Curve creation, review and
consumption have no owner, and the journey-extraction task for marketdata is
still BACKLOG. The candidates a journey pass should settle, one person and
one goal each, are:
| Candidate journey | Person | First consumer it creates |
|---|---|---|
| Build a curve for a currency and a date | Rates quant or market data engineer | The republish sender the server has never had |
| Keep a curve recipe right | Market data engineer | The recipe editor the desktop client took away |
| Read a curve and trust it | Rates quant | The curve read-back: points, shape, provenance |
| Correct a curve point | Market data engineer | Point-level override and re-publish |
| See how a curve changed | Risk reviewer or auditor | Build history and build-to-build comparison |
| Take a curve to ORE, and bring one back | Platform | The lossless curveconfig / market round trip |
They settle the model because each names an operation the server must serve:
a build journey needs a build, a comparison journey is what earns
curve_point, and a provenance journey decides which of
curve_point_provenance's fields are worth storing. They also force the
vocabulary question, because the actor classes in the journeys are
TenantAdmin and Operations today and neither is a rates quant.
7.4.8. Defects found on the way, independent of the representation
- The curve-snapshot reads are unauthenticated:
curve_snapshot_handlerchecks no permission and nocurve-snapshotcode is seeded (iam_permissions_populate.sql). This is a W02-class defect and belongs in its own capture. - The recipe's tenors are unvalidated at save time, as above.
CUBIC_SPLINEis recognised by the engine and throws;IMM_ROLLis superseded and throws.
7.4.9. Recommended next steps
- Agree the journey catalogue above with the owner (J08 of the extraction
standard), then run the marketdata journey extraction, reading the legacy
CurveBuilderWorkbenchfromgit show af67db3062^:projects/ores.qt/.... - Let the journeys choose between build-header-only and
curve_point, and write the chosen shape into the target model before any table is added. - File the curve-snapshot permission defect and the tenor-validation defect as captures; neither waits on the representation decision.
- Keep the ORE handoff out of this story, as it already is; the lossless export is the second journey's consumer and its own story.
7.5. Curve data model, 2026-10-06
A design pass against the existing tables and the
data-oriented design criteria, at
d02032a18c. It answers one question: which curve tables does the model need,
and how do they join to the ones already there? The joins below were run
against the recreated database.
7.5.1. The joins a curve needs already exist
A curve is a market_series whose oresmd URI names it. Its points are
market_observation rows of that series. Its inputs are per-point
observation_lineage rows. Its recipe is the refdata config and its pillars.
None of this needs a new key.
| Hop | From | To | Key | Note |
|---|---|---|---|---|
| Recipe to curve | ir_curve_bootstrap_configs.output_series_id |
market_series.id |
soft, no FK | cross-component; verified, one row |
| Curve to points | market_series.id |
market_observations.series_id |
hard FK | indexed; points are the curve |
| Points to inputs | (series_id, observation_datetime, oresmd_uri) |
observation_lineage same triple |
soft, natural key | per-point recipe and sources |
| Pillars to tenors | ir_curve_bootstrap_pillars.end_tenor_code |
refdata_tenors.code |
soft, no FK | verified for the seeded pillars |
| Point to tenor | market_observations.oresmd_uri coordinate |
refdata_tenors.code |
by code, via the URI codec | the coordinate is the pillar's end tenor |
The point-to-tenor hop is the one that has no column. That is deliberate: the
component removed its decomposed key columns and made the URI the single
identity, and the model says a reader that wants the key's parts reads them
through oresmd_uri_codec rather than off a row
(ores.marketdata.market_series.org). Adding a tenor column to
market_observation would reverse that decision, and it would pollute a
generic time-series table with one facet's coordinate. The relation is instead
carried where it is authoritative: by the recipe's pillar, whose tenor must be
validated (see below), with the point's coordinate equal to it by construction
(curve_republish_service.cpp passes point.point_id, the end tenor, to
datum_at_point).
7.5.2. The decision: the values add no table; the shape and the provenance do
The facts a build needs are already stored, or are derivable:
| Fact | Where it is today |
|---|---|
| Which curve | market_series.oresmd_uri (curve_id) |
| When the curve answers | market_observations.observation_datetime for its points |
| When the build ran, and its versions | the observation rows' bitemporal valid_from |
| Which recipe, and its version | market_series.derivation_config_id/version, and observation_lineage per point |
| What it was built from | observation_lineage.source_series_ids and source_as_of |
| Which values are quoted, interpolated or overridden | not stored; the curve_point_provenance proposal |
A build is therefore the (series, observation_datetime) slice of a curve
series' observations, and two builds are told apart by that slice, by the
bitemporal version within a slice, and by the recipe version. No table is
needed to represent that, and adding one would denormalise a fact the
observation hypertable already holds: the table is append-only, partitioned
on observation_datetime, carries no audit payload, and gives identity by
key. That is the shape a data-oriented model wants for the points.
The cold tables the requirements do imply are two, and neither is a second representation of the points:
- the shape tables, which state the object's axes and their ordered values so a hole is a hole (see Where is the shape? below), and
- the per-point provenance annex, keyed by the point and holding no value (see Manually keyed points below).
Both are cold data split away from the hot row, which is what the data-oriented criteria ask for.
7.5.3. If a first-class build is needed, it is a cold header
One consumer type would earn a table: a journey that treats a build as an object in its own right, with a state, a reviewer or a sign-off, rather than as a slice of a series. The prior Qt surface had exactly that shape, Build & Diagnostics then Publish, and the review/sign-off task is still a recorded intention. If that journey is agreed, the table is small and cold, and it joins without touching the points:
ores_marketdata_curve_builds_tbl id uuid -- surrogate key tenant_id uuid -- row-level security party_id uuid -- ownership, natural key part version integer -- bitemporal version valid_from timestamptz valid_to timestamptz series_id uuid -- FK -> ores_marketdata_market_series_tbl(id) reference_date timestamptz -- the instant the curve answers at; the join key build_kind text -- BOOTSTRAP | DERIVED | IMPORTED, validation fn source_as_of timestamptz -- when the inputs were observed -- the standard record provenance: modified_by, performed_by, -- change_reason_code, change_commentary natural key: (party_id, series_id, reference_date, build_kind) unique index: (tenant_id, party_id, series_id, reference_date, build_kind) current only index: (tenant_id, party_id, series_id, reference_date desc) FK: series_id -> market_series(id), list_by, order reference_date desc
Its one join is market_observations.series_id = builds.series_id AND
market_observations.observation_datetime = builds.reference_date, which the
existing observations_series_datetime index already serves. The recipe and
the inputs stay where they are: market_series.derivation_config_id for the
build, observation_lineage per point. The build table carries only what
neither holds, and it stays off the hot path: the kernel reads the points, not
the header.
The recommendation is to not create it until that journey is agreed, and to write the journey first if it is. This is the one place the curve model may add a table, and the journeys decide it.
7.5.4. The data-oriented layout
The three regimes get three shapes, and the storage shape is already the right one.
- Storage (Regime 2). The points are rows, not a blob: identity by key
(
series_id,observation_datetime,oresmd_uri), table-shaped children, core state split from the coldobservation_lineageannex, batched access through one repository call. The one decision to keep is that a curve must not be stored as a serialised snapshot on a single row: that would break bitemporality, the as-of read and the eventing that every other series has. Kernel (Regime 1, the bootstrap and any valuation). The existing
read_as_ofreturns an array of observation objects, each holding its value as text and its coordinate inside a URI string. A kernel should not walk that. Add one read that projects the same rows into arrays:struct curve { boost::uuids::uuid series_id; std::chrono::system_clock::time_point as_of; std::vector<std::chrono::sys_days> pillar_dates; // resolved tenors std::vector<double> discount_factors; }; curve read_curve(context, series_id, as_of); // one query, one parseThe value text is parsed once at the boundary, the tenors are resolved once, and the kernel sees stride-1 spans. No per-point object, no variant, no parse in the loop.
read_as_ofstays for the generic reader;read_curveis the kernel projection of the same rows, not a second source of truth.- Wire (Regime 3). The curve read is one batched message carrying parallel
arrays, not one message per point, and the existing
marketdata.v1.curve-snapshot.getis reshaped to it rather than joined by a second subject.
7.5.5. Does the storage hold up for the evolution read?
It does. The composite object is a series; its nodes are the series' point
observations at each instant; the hypertable already stores them that way, and
read_as_of_buckets already reconstructs snapshots. Nothing about the
evolution requirement needs a new table:
- Discovery by typed attributes — currency pair, object kind, party — is a wire and service concern: the request carries typed fields and the server resolves them to the series URI. If that lookup needs an index rather than a scan, the index goes on a derived projection of the URI's parts, not on a new table.
- Coordinate typing is a read concern: the server parses each node's
oresmd_urionce through the codec to produce the coordinate labels and to align the matrix. The identity stays the URI, as the component decided. - Alignment, shape change and the instant range are the evolution read's design, not the schema's.
What the evolution requirement does change is the priority: the aligned matrix read is the first thing to build, and the single-curve build journey is not.
7.6. The composite object is the requirement, 2026-10-06
A person does not ask for a series or a point. They ask for a term structure or a surface — a composite object with named axes — and then they ask how it changed. "Show me the FX vol surface for EURUSD over the last quarter" is the canonical question, and the acceptance below did not ask it: it asked for a curve's identity, one build and one consumer. Evolution, surfaces and axes were absent, and the word surface did not appear.
7.6.1. Today's answer to the canonical question, and why it is not easy
The storage supports the question. An FX vol surface is one market_series
whose points carry (expiry, strike_label) coordinates — the fx_option row
in datum/schema.hpp — and the observation hypertable is append-only and
partitioned on time. The retrieval does not:
- Discovery needs the URI string. Both reads are keyed by
oresmd_uri(get_curve_snapshot_buckets_request.oresmd_uri). "The FX vol surface for EURUSD" must be turned into the exact oresmd string by the caller; nothing indexes the identity's parts or lists the surfaces of a currency pair. - Evolution is a fixed window ending now. The one evolution read takes
bucket_secondsandbucket_count(5buckets of1800sby default) and ends at the present. There is no time range and no set of instants, so "over the last quarter" is not expressible. - The result is not aligned.
get_curve_snapshot_buckets_response.bucketsis avector<vector<market_observation>>: the client matches nodes across buckets itself, and each value is text. Nothing returns the surface as a matrix of instants by nodes. - Shape change is undefined. When the surface gains an expiry or loses a strike between two instants, the buckets simply hold different point sets. Nothing reports an added, dropped or persistent node.
- The object has no type. A curve, a surface and a grid are all "a series" with different coordinate shapes; nothing states which series are surfaces, or what their axes are. A generic evolution view cannot offer "all the vol surfaces for EURUSD" because it cannot tell one composite object from another.
So the data model does not make the FX vol surface evolution easy to retrieve, and the reason is the requirements, not the storage. The storage already holds the right rows; the reads and the acceptance never ask the question in the person's terms.
7.6.2. Where is the shape? There is no shape table, and there must be
The question is the right one, and the answer today is: nowhere. The seven marketdata tables hold the series identity, the points, the fixings, the asset-class junction, the lineage, the feed bindings and the classification rules. None of them says which tenors, expiries or strikes make up a term structure. The shape is inferred from the points and nothing else:
- For an observed structure the shape is whatever arrived. The set of tenors
is
SELECT DISTINCTover the point URIs, so a tenor missing because the feed dropped it is indistinguishable from a tenor the structure does not have. - For a bootstrapped curve the shape is the refdata recipe's pillars:
ir_curve_bootstrap_pillarcarriesstart_tenor_code,end_tenor_codeandsequence_index. That is close, but it is a build recipe in another component, it exists only for bootstrapped curves, and it describes the recipe's inputs rather than the term structure's nodes. - The oresmd schema declares the coordinate fields an instrument type carries — the axes — but no per-series values, so it cannot say which tenors a particular curve comprises.
Without a shape, several of the requirements below cannot be met. A missing node cannot be drawn as a hole. Completeness cannot be checked. "Which nodes were added or dropped between two instants" cannot be answered against an expected axis. And a surface's components cannot be enumerated without scanning history.
7.6.3. The shape is a first-class, cold declaration
The object's shape is its axes and their ordered values:
| Object | Axes | Axis values |
|---|---|---|
| A yield curve | term |
1M, 3M, 6M, 1Y, … |
| An FX vol surface | expiry, strike_label |
the expiries; ATM, 25RR, 25BF |
| A swaption cube | expiry, term, strike_level |
… |
A node is one value per axis; the grid is the product of the axes; an observation is the value at a node; and a node with no observation is a hole, which is exactly what the rendering, completeness, alignment and component requirements need. The tables are small and cold:
ores_marketdata_series_axes_tbl series_id uuid -- FK -> market_series(id) axis_field text -- an oresmd field code: term, expiry, strike_label, ... sequence integer unique current: (tenant_id, party_id, series_id, axis_field) ores_marketdata_series_axis_values_tbl series_id uuid axis_field text value text -- the code or level, validated where a vocabulary exists sequence integer unique current: (tenant_id, party_id, series_id, axis_field, value)
Both are cold: tens of rows per object, read once when an axis is drawn or a
build is validated, never on the point path. They join to the series by
series_id, and to the points by (series, axis attribute) against the
coordinate the read extracts from the point's URI. A point whose coordinate is
not a declared axis value is a defect, and the write-time check that refuses
it is what keeps the shape and the points from drifting.
For a bootstrapped curve the shape and the recipe's pillars are the same tenors
seen twice, so the rule is: the shape is the marketdata-side statement of
what the object comprises, and the recipe states how each node is built. A
build writes the shape from its pillars when it publishes, so the two cannot
drift; an observed or imported structure writes its shape from what it
declares, with no recipe at all. This is also where the tenor validation
lands: an axis value that is a term must be a refdata_tenors code.
7.6.4. The components: ATM, RR and BF are the surface's own axis
The vol surface's quoting convention is not a detail beside the surface; it
is one of its axes. The FX option key is
FX_OPTION/<quote>/unitCcy/ccy/expiry/strike, and ORE keeps the strike as a
label: ATM, 25RR, 25BF (ore_key_fx.cpp:56-64). So ATM, risk
reversal and butterfly are strike_label values, one per node, and each one
is a term structure over expiry — exactly the three the question names. That
decomposition is already in the data; the model exposes none of it:
- There is no read that returns a component. "The ATM ladder" is every node of
the surface whose URI carries
ATM, and the client finds them by parsing and filtering. - The labels are free text with no vocabulary and no validation, so
BF,FLYand25BFare three spellings the model cannot reconcile, and the delta convention they imply is not stored at all. - There is no "what are the components of this surface" read, so a screen cannot offer ATM, 25RR and 25BF without scanning history.
- The evolution read is URI-keyed and windowed, so "the ATM ladder over the last quarter" is not expressible either.
This is the same gap as the surface, one axis down, and it argues for the axis work rather than for a table: the components are slices of the surface's grid, not independent objects.
7.6.5. One series per surface, or one per component?
The question this raises is whether ATM, RR and BF should each be a series of their own, grouped into a surface, rather than a coordinate of one surface series. The answer turns on what changes and what is consumed:
- A market quote is a
(pair, expiry, strike label)point. The grid is one object quoted by one convention, and a change to its shape is a point change. One series per surface makes that an ordinary bitemporal point write. - One series per component would make a shape change a change of group membership with its own history, which is a junction with validity rather than a point, and it would spread one object's identity across N series.
- The components are almost always read together with the surface, and the curve set storage grouping is for grouping independent series — a funding curve and its projections — not for slicing one grid.
So the recommendation stays: the surface is one series; ATM, RR and BF are values of its strike-label axis, and what they need is a typed, named, validated axis and a read that returns a slice, not a series and not a table.
7.6.6. Manually keyed points
Users key points by hand: a trader's IR level, a broker's vol, a correction after a bad feed. That is a first-class market-data operation, not an anomaly, and it needs three facts the model does not store at a point today: that the value is manual, who keyed it, and when.
Two facts decide the design:
- The hot row must not carry them.
market_observationisno_audit_columns, append-only, and the hot path. Adding actor and reason columns would put cold metadata on every one of its rows and undo the hot/cold split the model already has. - A manual point is an ordinary bitemporal write. The value has the same
shape as any other point, and the table's
soft_update_deletetrigger already closes the prior row and inserts the new one. The override therefore needs no value storage of its own: the manual write is a normal write, and the history keeps both the fed value and the manual one.
What is missing is the point's provenance, which is cold and belongs in the
existing per-point annex. observation_lineage is keyed by the same triple
and already carries modified_by, performed_by, change_reason_code and
change_commentary. What it lacks is the two things a manual point needs in
order to be told from a fed or derived one:
- a point source kind — quoted, interpolated, extrapolated and manual (the curve point provenance proposal's derivation kind, extended with manual), and
- the actor and time at point granularity, which the annex has and the observation does not.
The clean shape is one per-point cold annex, not a second table keyed the same
way: either broaden observation_lineage into the point-provenance annex that
document already proposes, or rename it to say what it now is. Two cold tables
keyed by (series_id, observation_datetime, oresmd_uri) would be a smell.
7.6.7. Precedence: a feed must not silently undo a manual point
Because the manual write is bitemporal, a later feed tick would close the manual row and take over, so the precedence is a policy the model must state rather than a consequence of write order. Either the point is marked locked in its provenance and the ingest skips a locked point, or the read prefers the latest manual point over the latest automatic one until the manual point is cleared. Either way the rule is explicit and testable, and the evolution view can show the manual segment with its actor and its reason.
7.6.8. The requirements the use case implies
- A composite object is first-class: identifiable and of a kind (term structure, surface, grid), with its axes named, before any point is read.
- Its shape is declared: the axes and their ordered values are stored as a cold, per-object declaration, so a node with no value is a hole and not an absent node, and completeness can be checked.
- Its nodes have typed coordinates – terms, expiries, strikes or deltas, dimensions – that can be queried, joined and aligned, not only text inside an identity string, and every node coordinate is a value of the declared shape.
- Every axis value is a typed, validated code with a vocabulary, so
ATM,25RRand25BFare canonical and a component can be named. - The components of an object along an axis are retrievable as term structures in their own right, over time: "the ATM ladder of the EURUSD surface, over the last quarter" is one request.
- Evolution is a first-class read: one bounded request returns the aligned matrix of instants by nodes, with the coordinate labels and the values.
- Shape change is explicit: the read reports nodes added, dropped and persistent against the declared shape.
- The single-instant read, the slice read and the evolution read share one definition of the object, so they cannot disagree.
- Every node's value carries its provenance per instant, so the view can explain a change.
- A point's source kind is stored per point – quoted, derived or manual – and a manual point names the actor, the time and the reason.
- A manual point is not silently overwritten by a later automatic write; the precedence between the two is explicit.
- The evolution view distinguishes manual points and shows their actor and reason, so a person can see what was keyed by hand.
7.6.9. What to add, in order
Items one to five are done. Items six to eight and ten are now BACKLOG tasks, named under each item. Item nine closed before it was ever a task.
- The composite-object identity at the wire boundary: a typed request (asset class, scope such as a currency pair, object kind, party) that resolves to the series, so "the FX vol surface for EURUSD" needs no hand-built URI. Task Resolve a composite object from its typed identity, DONE.
- The shape tables: the per-object axes and their ordered values, written
from the recipe's pillars by a build and from the declaration by an import,
with every term value validated against
refdata_tenorsand a write-time check that refuses a point whose coordinate is not a declared axis value. That validation is of a declared shape, not of a pillar a person saved, so it does not close captureA6472918. The capture is promoted to its own story, Validate a curve pillar's tenor codes, which stays open. Tasks Give a composite object its shape tables, Refuse a point its shape does not declare and Let an import declare the shape it carries, all DONE. - The axis values as typed, validated codes: the strike-label vocabulary and the quoting convention, so ATM, 25RR and 25BF are canonical and the components of a surface are enumerable. Task Type the axis values a composite object varies over, DONE. The label is a codec type. A per-surface quoting convention stays with journey 2.
- The slice read: a component along one axis – the ATM ladder – returned as a term structure over time, from the same object definition. Task Read one slice of a composite object over time, DONE. The instants are the object's, the ladder is the shape's, and an object of more than two axes is refused.
- The aligned evolution read: the object plus a range or a set of instants, returning instants by nodes with the coordinate labels and numeric values, and the added, dropped and persistent nodes against the declared shape. It replaces the fixed five-bucket window and returns a matrix, not arrays of objects. Task Read a composite object's aligned evolution, DONE. The instants are the points an instant states, the grid is the shape's product, and the fixed five-bucket window is gone.
- The single-instant read reshaped to the same object definition, so the three cannot disagree. Task Reshape the single-instant read to the object definition, DONE. The snapshot names the object by its typed identity and returns the shape's nodes.
- The per-point provenance annex: the source kind (quoted, derived, manual),
and for a manual point the actor, the time and the reason, in one cold
annex keyed by the point's natural key. Name it once – broaden
observation_lineageor rename it – rather than adding a second cold table beside it. Task Give every point a provenance annex, DONE. The annex was one table already; the task adds the read, on the snapshot, when asked. - The manual precedence rule: what a later automatic write does to a manual point, stated as a policy and covered by a test. Task State what a later automatic write does to a manual point, DONE. A manual point owns its coordinate until it is cleared.
Guard the curve-snapshot reads (captureClosed before this list became tasks. The handler checksD9258D1F).marketdata::curve_snapshots:readand the permission is seeded, see What the tree has moved since the earlier passes below.- Only then decide the
curve_buildstable, from the agreed journeys. Task Decide the curve_builds table, DONE. No build table. The journeys earn a publication record, added with the publish operation.
7.7. Journey to model reconciliation, 2026-10-07
A third read-only pass, at 44b4fa36a4 (origin/main). It reconciles the
curve data model against the signed-off journey catalogue, reads the tables,
the generated repositories and the SQL directly, and corrects the two earlier
passes where the tree has moved. Nothing here writes code, SQL or a generated
file.
7.7.1. What the tree has moved since the earlier passes
- The curve-snapshot reads are now authenticated and their permission is
seeded.
curve_snapshot_handler.hpp:81-85and:121-125checkmarketdata::curve_snapshots:read, andprojects/ores.sql/populate/iam/iam_permissions_populate.sql:1519seeds it. TheD9258D1Fdefect is closed. The deeper analysis's "the handler checks no permission and no curve-snapshot code is seeded" (story :306-309) is stale. instrument_type::curveandto_curve_key()no longer exist. The onlyinstrument_typeis ORE's MarketDatum list (ore_types.hpp:38-84), which has nocurvemember. The generated grammar that held them was retired, so the open question that assumed them is moot.curve_republish_service.cpphas moved. The story's citations (:182,:224,:296,:299) are fromd02032a18c. Today the stamp is:171-187, thebootstrap()call is:256, the observation write is:324-333with the insert at:363, and the lineage write is:344-359with the save at:365-366.- The database holds no built curve. One bootstrap config exists; its output
series is still
derivation_kind'OBSERVED'= and has zero observations. Three lineage rows exist in total and none is current. A republish has never run outside the tests, so every "today" statement below about a built curve is a statement about code paths the tests exercise, not about data.
7.7.2. Every journey, what serves it, and the gap
The gap counts are the journey documents' own "What is missing" items under
doc/knowledge/journeys/market_data/, except journey 15, which is under
.../operations/. "Serves it today" names the table, read or code that exists
now, with a file and a line.
| # | Journey | Serves it today | Gap | Decision |
|---|---|---|---|---|
| 1 | Manage a curve's blueprint | refdata config and pillar CRUD (ir_curve_bootstrap_config_protocol.hpp:95,118,155; ir_curve_bootstrap_pillar_protocol.hpp:93,116,153); read in curve_republish_service.cpp:131-152 |
7: no name column; tenors unvalidated (pillars create:108 validates only curve_role_code); config plus pillars not atomic; no web page; no approval kind |
No blueprint table. The only schema worth adding here is the tenor reference, and it lands as the axis validation below. |
| 2 | Author a surface's framework | market_series plus the asset-class junction; generic snapshot read |
7: no surface model; no axis or grid storage; no strike-label vocabulary; no quoting convention; no parametric model | Deferred. A surface needs a model and an axis vocabulary the curve task does not add. Named gap with its own story. |
| 3 | Watch the market and clear its exceptions | market_observation and feed_binding rows; feed_ingest_loop.cpp:264 writes ticks |
7: no quality or rejection; no exception entity; no typed scope; no resolution record; source is free text |
The exception entity is out of scope. The point source kind below is the only representation touch, so a view can tell a feed point from a manual one. |
| 4 | Bootstrap or refresh a curve | curve_republish_service::compute() (:276-284=) and republish() (:286-370=); engine (curve_bootstrap_engine.cpp:111-262); wire handlers registered at application.cpp:199-210 |
7: nothing sends the subjects; no candidate object or state; no approval; no candidate marker (:171-187 stamps only on first republish); no per-pillar diagnostics; reads have no as-of; no typed curve identity |
The build stays the (series, observation_datetime) slice. No build table until the review/approval journey is agreed. The compute/preview web operation is the first consumer to build. |
| 5 | Calibrate or refresh a surface | Nothing. Only the generic snapshot read | 7: no surface model, engine, quoting convention or calibration read | Deferred with journey 2. No schema in this task. |
| 6 | Adjust a curve or surface by hand | put_market_observation (market_observation_service.hpp:85-88); the shared insert at market_observation_repository.cpp:114-116, used by ingest (feed_ingest_loop.cpp:264) and republish (curve_republish_service.cpp:363) |
7: no source kind; no actor; no reason; no override or clear operation; no precedence; no history for the point | The core of this task. Extend the cold point annex with a source kind; reuse its record provenance for who, when and why; state and test the precedence rule. No new table. |
| 7 | Check a curve's fit | compute(); lineage read; recipe reads |
6: no diagnostics subject; curve_health_checker has no production caller; quotes and candidate not joined; resolved pillar date not stored; no as-of read |
Diagnostics are a wire and read addition. The resolved date is derived, not stored: the recipe plus the build as-of determine it, so a stored copy would be a second source of truth. |
| 8 | Inspect a surface's slices | Nothing; only the series and observation reads | 6: no surface, slice or mesh read; no arbitrage check; no typed identity; no as-of | A slice is a filtered view of the same points over an axis, not a table. Deferred with journeys 2 and 5. |
| 9 | Ask for a publish to be approved | Generic inbox raise and decide (approval_operations_protocol.hpp:44,107,144; approval_kind_protocol.hpp:95) |
6: no market-data approval kind; no candidate set; no diff; no apply operation; no notification kind | No schema here. The candidate set and the apply operation are the publish journey's work. |
| 10 | Decide a publish | Generic inbox queue, read and decide | 6: same kind gap; no diff; no validation operation; no kind detail table | No schema here. |
| 11 | Publish the approved set | republish() writes derived observations unconditionally (:286-370); market_series history provider |
6: no publish operation; no published marker; no notification kind; no publisher name; no candidate set | If a published marker is needed, it belongs on market_series as an ordinary state stamp. The series already carries bitemporal history and record provenance; no build table. |
| 12 | Withdraw or replace a published curve | Nothing. The only write is republish() |
7: no withdraw or replace operation; no published state; no supersession link; no notification kind; no consumer map; no diff | Supersession is the series' existing bitemporal history plus a state stamp. A publish record is deferred with the publish journey. |
| 13 | Watch a curve or surface evolve | read_as_of (market_observation_repository.cpp:418-451) and read_as_of_buckets (:453-516=) behind the snapshot handler (curve_snapshot_handler.hpp:73-164, registered at registrar.cpp:96-109) |
6: no as-of or range; no set of instants; no alignment; no declared shape; no typed identity; no published marker | The aligned read is the first consumer work. It overlays the point source kind on the existing rows. The declared shape is a named gap, not part of this task's minimal representation. |
| 14 | See who changed a curve, and when | Generic history.get (history_protocol.hpp) with providers for market_series, observation_lineage, feed_binding and series_classification_rule (registrar.cpp); market_series_versions.list (market_series_protocol.hpp:227) |
7: market_observation registers no history provider; no publish acts; no market-data approval kind; no candidate distinction; lineage read keyed by id; manual points unmarked; no auditor role |
The manual point's author and reason live on the annex, which already registers a history provider, so the auditor trail exists once the annex row is written. No new table. |
| 15 | Keep the market data feeds running | feed_binding CRUD; feed_ingest_loop.cpp; synthetic start and stop subjects |
7: no last-tick read; no source-to-series mapping; IR configs not listable; start and stop are shell-only; no silence threshold; no status history; enabled differs from running | Unchanged. The ingest insert is not the precedence choke point, because the chosen rule is read-time. |
7.7.3. The five join hops, confirmed and corrected
The story's "Curve data model" table (story :341-347) claims five hops. Four hold; one is wrong about its key.
| Hop | Story's claim | Verified today | Verdict |
|---|---|---|---|
| Recipe to curve | ir_curve_bootstrap_configs.output_series_id to market_series.id, soft, no FK, verified one row |
The column is output_series_id on ores_refdata_ir_curve_bootstrap_configs_tbl (refdata_ir_curve_bootstrap_configs_create.sql:85), with no FK; the one config's output series resolves (create.sql:118-119 holds it unique per tenant) |
Correct |
| Curve to points | market_series.id to market_observations.series_id, hard FK, indexed |
The observations table has no references clause (marketdata_market_observations_create.sql:41-56), and pg_constraint holds zero foreign keys on any ores_marketdata_* table |
Corrected: soft, not hard. The index exists (create.sql:64-65), so the join is fast, but nothing enforces it |
| Points to inputs | (series_id, observation_datetime, oresmd_uri) to observation_lineage, soft natural key |
The lineage table's current-row unique index is that triple (marketdata_observation_lineages_create.sql:84-86), with no FK; the lookup is read_latest_by_observation (observation_lineage_repository.cpp:369) |
Correct, but the annex exists only for derived points; a quoted point has no row |
| Pillars to tenors | end_tenor_code to refdata_tenors.code, soft, no FK |
No FK, and the pillar trigger validates only curve_role_code (refdata_ir_curve_bootstrap_pillars_create.sql:107-108), not the tenors |
Correct and unvalidated |
| Point to tenor | the oresmd_uri coordinate to refdata_tenors.code by code through the codec |
republish() passes the engine's point.point_id, which is the pillar's end tenor, into datum_for() (curve_republish_service.cpp:306-307 and curve_bootstrap_engine.cpp:257-258); the stored discount point reads its term back (:103-109) |
Correct. The coordinate equals the pillar by construction; no column is needed |
Two further facts the hops table should state: market_observations carries no
point_id and no tenor column (columns at create.sql:42-51), and
observation_lineage is not written for observed points, so "points to inputs"
is a hop that exists only for derived points.
7.7.4. Open questions, answered
- How does a curve read its points? Today
read_as_ofreturns an array ofmarket_observationobjects whose value is text and whose coordinate is inside a URI (market_observation_repository.cpp:418-451). Decision: add theread_curveprojection returning parallel arrays, as the story's data-oriented layout asks (:445-463=). It is a read over the same rows, not a table, and it matches the Regime 1 criteria for SoA and stride-1 access (data_oriented_design.org:162-167). The engine already consumes avector<bootstrapped_point>, so the kernel is one parse away from the projection. - An entity or a shaped series? Decision: a shaped series plus the cold
point annex, not a
curve_pointtable. The points must be bitemporal, versioned, evented and hypertable-backed exactly as observations already are (create.sql:118-138); a second point table duplicates that stack, and the story already priced it (:233-238=). - Does a curve need its own table when it is one series plus points? No.
Identity is
market_series.oresmd_uri(marketdata_market_series_create.sql:61), and the series already carries a validated taxonomy inseries_subclass(18 seeded codes atrefdata_series_subclass_codes_populate.sql:43-96, includingyieldandvolatility) and a derivation marker (create.sql:64-66). A curve-vs-surface kind is the coordinate shape, which the read derives from the URI's declared coordinate fields (schema.hpp:304-386). Reviving acurveenum is not needed. - What consumes a curve? Nothing in production. The only caller of
curve_republish_serviceis its own wire handler (curve_republish_handler.hpp:67-145, registered atapplication.cpp:199-210), and no web route, HTTP route or shell verb sends either subject. The client package has no curve call and the shell has no curve command. The engine's other callers are its tests, andcurve_health_checkerhas no production caller at all. Decision: record that there is no consumer and keep the representation minimal and reversible; the first consumer is the compute or the evolution read, built before any table is added. - Does the oresmd
curvevocabulary become load-bearing? Moot.instrument_type::curveandto_curve_key()were deleted with the generated grammar; the URI is the identity. No enum is revived. - Is
IR_CURVE_BOOTSTRAPthe whole story? No.CRM_DERIVATIONis seeded and unused (refdata_derivation_kinds_populate.sql:44). Decision: put the point source kind on the generic annex so CRM and any later derived kind reuse it unchanged, which is what that table's own doc says it is for (ores.marketdata.observation_lineage.org:25-30). - Where does the recipe belong? Leave it in
ores.refdata. Its config already owns CRUD, history and row-level security there (refdata_ir_curve_bootstrap_configs_create.sql:29-33), andmarket_series.derivation_config_idis the established soft reference. A move would duplicate refdata and break the soft-reference principle. Decision: keep it, and close the tenor-validation gap on the recipe side. - How does a curve relate to
feed_bindingandobservation_lineage?feed_bindingmaps a producer channel name to a party (feed_binding.hpp:65,93); lineage records the series a derivation read (observation_lineage.org:166-177). Decision: a derived curve has no feed binding; its inputs are series ids, andrepublish()already writes those intosource_series_ids(curve_republish_service.cpp:311,354). No change.
7.7.5. The smallest representation that satisfies the acceptance
The acceptance demands four things the model lacks: a point's source kind, a manual point's author/time/reason, a precedence rule, and one read that returns a component's ladder over time. Three are provenance and one is a read. None needs a new table.
Where the point's provenance lives. On the existing cold per-point annex,
ores_marketdata_observation_lineages_tbl. A point with no annex row is
quoted: absence is the default, and quoted ticks keep writing one row to the
hypertable and nothing else, which preserves the hot/cold split the model
already has (marketdata_observations_create.sql:37-38). A point with an annex
row is derived or manual, named by one new column:
point_source_kind text not null -- 'derived' | 'manual', validated
Everything else the manual point must name is already on that annex: modified_by
is who keyed it, change_reason_code with change_commentary is why, and the
annex's bitemporal valid_from is when (marketdata_observation_lineages_create.sql:66-71).
The annex already registers a history provider
(observation_lineage_history_provider_registrar.cpp, fields at
observation_lineage_history_field_mapper.cpp:41-62), so the auditor trail and
the per-point provenance read exist for free once the row is written. Adding a
second cold table keyed by the same triple would be a smell, exactly as the
story says (:682-685=).
Two constraints have to move so a manual row can exist: derivation_config_id
and source_as_of must become nullable, and the
jsonb_array_length(source_series_ids) > 0 check must allow an empty array,
because a manual point has no derivation config and no source series
(create.sql:62-65,80). These are edits to the entity model
(ores.marketdata.observation_lineage.org:52-204) followed by a regenerate,
never a hand edit of the generated SQL or header.
This is the smallest representation that works. It is also the most data-oriented: cold provenance stays in a cold annex keyed by the point, the hot observation row is untouched, and no value is duplicated.
How the precedence rule is enforced and tested. Decision: read-time
precedence. A current annex row with point_source_kind'manual'= owns its
(series_id, oresmd_uri) coordinate for reading until it is cleared.
What an as-of instant means here, stated because it is easy to get backwards.
An as-of timepoint is a market instant, and the read for that instant must
take manually keyed points into account. A manual point whose own instant the
snapshot covers belongs in that snapshot whenever it was keyed, this morning or
five years ago: over-keying is an operator correcting what the curve is for
that instant, not a statement about when they said so. So applicability is
decided by the point's instant alone, and the annex's valid_from does not
decide it. A manual point whose instant falls after the snapshot's instant
belongs to a later instant and is not part of this snapshot.
The rule is one pure predicate over the kind of the latest automatic value and
the active manual value, applied by read_as_of and read_as_of_buckets
(market_observation_repository.cpp:418-516) as a small overlay over the cold
annex, not a join in the hot loop. Read-time is chosen over a write-time guard
because the manual write is an ordinary bitemporal write
(create.sql:77-99 closes the prior row and inserts the new one), so the
history keeps the fed value and the later feed values, which the acceptance
requires; a write-time skip would discard them. The overlay is over tens of
manual points, not over every tick, so the aligned read stays contiguous.
The test lives in projects/ores.marketdata/core/tests/repository_market_observation_repository_tests.cpp,
beside the existing read_as_of cases (:135-226). It writes a feed point,
writes a manual point for the same coordinate, writes a later feed point, and
asserts read_as_of returns the manual value and read_as_of_buckets shows it
in every bucket whose boundary is at or after the point's instant, which is the
bucket where it starts to apply. A second case flips the
predicate's preference and asserts the feed value wins, so the test fails when
the rule is reversed, which is the acceptance's own wording.
What is deliberately not added. No curve_build, no curve_point, no
curve entity, and no shape table in this task. The build stays the slice
republish() already writes (:324-333), and identity stays the series. The
declared shape and the aligned read are the first consumer's work; the shape
tables are a named gap with a decision (journey 13) and can be added when the
evolution read's added/dropped/persistent requirement is implemented.
7.7.6. The first production consumer
None exists today, and the acceptance allows the record to say so. The bootstrap engine is called by the republish service and by its own tests; the health checker by its own tests; the snapshot reads by their own handler; and no sender, page, route or verb reaches the handler. The consequence for the representation is direct: because no consumer exists, the representation must stay minimal, reversible and cold, and the first unit of work is the consumer, not a table. The cheapest real consumer is journey 13's aligned evolution read over one series, because it needs only the rows and the provenance overlay above, and it proves the object definition that the single-instant and slice reads will then share. The compute preview of journey 4 is the second, because it needs no storage at all.
7.7.7. Decisions taken here
- Keep the series as the identity; add no curve, build or point table.
- Put the point's source kind on the existing cold annex; quoted is the absence of a row.
- Reuse the annex's record provenance for a manual point's author, time and reason; add no fields for them.
- Enforce the manual precedence at read time with one pure predicate, and test it in the existing repository test file so a reversed rule fails.
- Build the aligned evolution read first; it is the first consumer and the first user of the point source kind.
- Correct the joins record: the curve-to-points hop is soft, not a hard FK.
- Treat the snapshot-permission defect as closed and the oresmd curve vocabulary as deleted; strike both from the open questions.
7.7.8. What this pass could not determine
- Whether a consumer wants the raw feed value beside an active manual value at the same instant. Read-time precedence can return both in one response; a journey has to say whether it should. Its answer decides whether the overlay is a filter or a second field, not whether a table is needed.
- Whether
curve_buildis earned. That turns on the approval and review journey (9-12), which has no owner and no prototype beyond the wireframe. The decision to defer it is recorded, not derived from a consumer. - The rename question: whether the annex should be renamed to say it now carries a point's provenance and not only a derivation's lineage. It is a mechanical codegen rename with no behavioural effect, and it is left to the task that adds the column.
- The exact vocabulary for
point_source_kind. Settled: the column carriesderivedormanual, and quoted is the absence of an annex row. The acceptance's three kinds are all answerable, because a reader classifies a point in one step: no annex row means quoted, and a row names the other two. Absence carries quoted rather than a third stored value because of the hot/cold split. A quoted tick is the common case and writes one row to the hot hypertable and nothing else; storing 'quoted' explicitly would write a cold annex row for every tick in the system, which is the cost the split exists to avoid. The invariant this rests on is that a point a derivation created always has an annex row, so absence cannot mean derived. The wider set the provenance proposal carries – interpolated, extrapolated, flattened, back-filled – stays open, and adding it later changes no representation, only the check on this one column.
7.8. Journey catalogue, signed off 2026-10-06
The catalogue was agreed with the owner on 2026-10-06. The journey documents
are written against it under doc/knowledge/journeys/market_data/, with the
feeds journey under doc/knowledge/journeys/operations/.
7.8.1. The model the journeys assume: observations are not curves
- A market observation is what the market sends. It arrives per point, from a feed or an import, and nobody signs it off. It is the evidence.
- A published curve or surface is what the system uses. A trader holding the market-data management role owns it, updates it from what they see in the market or adjusts it by hand, and publishes it once the configured approvals are given. Only the published object is visible to pricing, reporting and every other consumer.
7.8.2. Actors
| Actor | Who they are | Note |
|---|---|---|
| Trader (maker) | The front-office person who owns a currency's curves or a desk's surfaces | Holds the market-data management role |
| Approver (checker) | A person who decides a publish | Holds the decide permission for the approval kind |
| Quant | Authors models and diagnostics; may hold the same role | |
| Operations | The installation's feeds and services | |
| Consumer | Any user who reads published market data | |
| Auditor | Reads the trail after an incident |
7.8.3. The catalogue
| # | Journey | Actor | Group |
|---|---|---|---|
| 1 | Manage a curve's blueprint | Trader, Quant | market_data |
| 2 | Author a surface's framework | Trader, Quant | market_data |
| 3 | Watch the market and clear its exceptions | Trader, Operations | market_data |
| 4 | Bootstrap or refresh a curve | Trader | market_data |
| 5 | Calibrate or refresh a surface | Trader | market_data |
| 6 | Adjust a curve or surface by hand | Trader | market_data |
| 7 | Check a curve's fit | Trader, Quant | market_data |
| 8 | Inspect a surface's slices | Trader, Quant | market_data |
| 9 | Ask for a publish to be approved | Trader | market_data |
| 10 | Decide a publish | Approver | market_data |
| 11 | Publish the approved set | Trader | market_data |
| 12 | Withdraw or replace a published curve | Trader, Approver | market_data |
| 13 | Watch a curve or surface evolve | Consumer | market_data |
| 14 | See who changed a curve, and when | Auditor | market_data |
| 15 | Keep the market data feeds running | Operations | operations |
7.8.4. Decisions taken at sign-off
- Journeys 3 and 4 of the first draft are one screen, now journey 3: the market's exceptions are cleared where they are seen.
- The two read journeys are one screen, now journey 13: one instant and a range are the same view at different zoom.
- Approvals are generic and configurable. An approval kind carries how many approvals are needed and which group decides, so a publish may need zero, one or n approvals. A withdrawal uses the same rule.
- The ORE export is not a journey. It is excluded from this catalogue and the data model must still cater for it; a later task owns it.
- The feeds journey moves to the
operationsgroup, because Operations owns it and it is not about curves.
7.9. Acceptance
Read every bullet in the person's terms: the composite object first, its evolution second, the representation third.
- A composite object is first-class. A term structure or a surface is identifiable, and of a kind, with its axes named, without knowing which strings to combine. "Is this a curve or a surface" and "which one is this" are answerable from the data.
- Its shape is declared. The axes and their ordered values are stored, so a node with no value is a hole and not an absent node, completeness can be checked, and every node's coordinate is a value of the declared shape.
- Its nodes are typed coordinates. A node's coordinate tuple — terms, expiries, strikes or deltas, dimensions — is queryable and joinable, not only text inside an identity string. Met in the service tier: see A coordinate is typed in the service and is not a column.
- Its axis values are typed codes. ATM, 25RR and 25BF are validated vocabulary, so a component can be named and reconciled, and the quoting convention they imply is recorded.
- A component is a term structure in its own right. The ladders along one axis — the ATM, RR and BF term structures of a vol surface — are retrievable over time in one request, as term structures, not filtered points.
- Evolution is one bounded read. Given the object and a time range or a set of instants, the server returns the aligned matrix of instants by nodes, with the coordinate labels and the values. "The FX vol surface for EURUSD over the last quarter" is one request, not a client-side join.
- Shape change is explicit. Nodes added, dropped and persistent between instants are reported, never silently different point sets under the same object.
- A node's provenance is readable per instant: what it was built from, by which recipe, and when.
- Staleness is visible whenever a term structure is drawn. A curve's points rarely share one observation instant, so a plot can mix horizons without saying so. Every point carries how old it is, and the view shows it, so a reader never mistakes a composite for a single market state.
- A point can be keyed by hand. A manual point or override is written as an ordinary point, and the history keeps the value it replaced.
- A manual point is attributable. A point says whether it is quoted, derived or manual; a manual one names who keyed it, when and why, so it is never indistinguishable from a fed or derived point.
- A manual point is respected. A later automatic write does not silently undo it; the precedence rule is explicit and tested.
- The single-instant, slice and evolution reads agree, because they share one definition of the object at an instant.
- At least one production consumer reads the object, or the story says in the record why none does.
- The three role vocabularies are either reconciled or documented where a reader meets them, because two of them share a name.
- No new name collision between this work and the oresmd layer's existing
curvevocabulary. - Every market data journey has a plain-HTML prototype, the prototypes are listed in one inventory, and the inventory check is green.
7.10. Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Add prototypes for all market data journeys | DONE | 2026-10-06 | 2026-10-06 | One prototype per market data journey, the oresmd URL editor every screen calls, and the plotting for term structures and surfaces. |
| Check the curve model against the journeys and start the representation | BACKLOG | Settle the shape the journeys demand against the tables that exist, give a point its provenance and a manual key its author, and start the representation. | ||
| Show how stale each point of a term structure is | DONE | 2026-10-08 | 2026-10-08 | A term structure is drawn from points that rarely share an observation instant, so a plot can mix market horizons without saying so. Every point carries its age and the view shows it. |
| Let an operator key and clear a curve point by hand | DONE | 2026-10-07 | 2026-10-07 | A manual point can be recorded and read, but nothing in the product can key one. Add the write and clear path, so an operator over-keys a coordinate and withdraws it, with the actor and reason recorded. |
7.11. Decisions
Why this is a story and not a task under the clean-up. The clean-up brings ores.marketdata up to the Component Clean Standard. This changes the data model, adds an entity or a shaped representation, and needs a consumer that does not exist. Folding it into the clean-up would put a modelling decision inside a mechanical PR.
Why it does not start yet. The clean-up is still moving the models, projections and import boundary. Landing a curve representation on top of a model that is still in motion would mean doing it twice.
7.12. Out of scope
- The ORE handoff — writing
curveconfig.xml,market.txt,todaysmarket.xmland readingcurves.csvback. It is a prerequisite for a lossless curve round trip and it is large enough to be its own story, but it is not this one. - Changing how ORE itself builds curves, and any ORE-side configuration format.
- The synthetic generator's curve feeds (
ir_curve_feed,ir_curve_generation_config). They publish curve points as ticks and are a producer, not a representation.
7.13. Open questions for the deeper analysis
Answered by the Deeper analysis, 2026-10-06 section above; the questions
are kept as the record of what was asked.
- How does a curve read its points? The grammar story keeps the 45-way variation in the datum identity, which is cold, and leaves the hot side here: a series' points as parallel arrays per coordinate shape (about six: none, term, contract, expiry by strike or delta, maturity by strike, and the shape-profile slot), batched, with each point referring to its series by index. Curve and surface building should read that shape, per the data-oriented design criteria for kernels.
- An entity or a shaped series? Sprint 25 proposed
curve_buildandcurve_pointtables. The shipped implementation reused the observation tables instead. Which is right, given that a curve's points must be bitemporal and versioned the way observations already are? - Does a curve need its own table when it is one series plus points? If
not, what is the minimal thing that gives it identity — a marker column, a
reserved
series_type, a view, or the oresmd URI? - What consumes a curve? Nothing does today. Until something does, every representation decision is speculative, so the analysis should establish the first real consumer before choosing a shape.
- Does the oresmd
curvevocabulary become load-bearing here?instrument_type::curveis dead andto_curve_key()is gated ontype=fixing. If a curve gains identity, is the URI its identity, and does =to_curve_key() then mean what its name says? - Is
IR_CURVE_BOOTSTRAPthe whole story?CRM_DERIVATIONis seeded and unused, so there is at least one other derived kind waiting. Does the shape chosen generalise to it? - Where does the recipe belong? It is in refdata today behind soft references. Should a curve and its recipe live in one component?
- **How does a curve relate to
feed_bindingandobservation_lineage?= Lineage is documented as deliberately generic, and feed bindings map a raw producer channel to a series. A curve's inputs are a series, not a channel.
7.14. Staleness, 2026-10-07
A term structure is drawn from points that rarely share one observation instant. One pillar may have ticked a minute ago and the next three days ago, and the plot looks like a single market read when it is a composite of several. Worse, the mix is usually invisible: a stale pillar's value is a real value, in the right place, at the wrong time.
The rule: whenever a term structure is drawn, every point carries how old it is, and the view shows it. The reader must be able to tell a curve built from one market state from a curve stitched across several, without asking.
What each point needs, and where it already is:
| Fact | Source |
|---|---|
| The instant the point represents | market_observation.observation_datetime |
| When that value was recorded | the row's bitemporal valid_from |
| Whether it is fed, derived or manual | point_source_kind on the annex, or the absence of a row |
Two ages are readable and they answer different questions. Market staleness,
the snapshot instant minus the point's instant, says the point belongs to an
older market state than the snapshot claims. Record staleness, now minus the
point's valid_from, says how long ago the value arrived. A curve read as of
one instant has the first for its spread; a curve read as of now shows the
second. Both are derivable from rows already selected, so no column is added
and no new table is needed.
Proposed encoding, for the prototype to settle:
- Per point: an age band, so a stale pillar is visible without reading a number.
- Per curve: the oldest point's age, and the spread between the oldest and the newest, because the spread is what measures the mixing.
- A warning when the spread crosses a threshold the journey states, rather than a silent plot.
This is a requirement on the read as well as the drawing: a consumer that returns points without their instants cannot draw it. The aligned evolution read is the first place it lands, since that read already returns instants by nodes, so the age comes with the matrix rather than from a second call.
7.15. References
- ORE engine source:
/home/marco/Vault/Development/ORE/Engine— the real checkout, not the vendored copy underexternal/ore/. Start atOREData/ored/configuration/curveconfig.hppfor what a curve config is, andQuantExt/qle/termstructures/for how one is built. - Task: Bring ores.marketdata to the clean standard — the work that must close first.
- Story: Clean ores.marketdata to the component clean standard
doc/agile/versions/v0/sprint_26/clean-marketdata/analysis.org— the clean-up's analysis, including the PR 4 boundary work this story depends on.doc/knowledge/domain/interest_rate_curves.org— what a curve is.doc/knowledge/domain/funding_and_projection_curves.org— the two-tier family the recipe encodes.doc/knowledge/domain/curve_sets_storage.org— the storage-grouping concept, which is not what this story is about but shares the vocabulary.doc/knowledge/domain/curve_point_provenance.org— provenance, the second acceptance bullet.