Story: Add proper curve support to ores.marketdata

Table of Contents

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_series is already the identity of a term structure; the oresmd URI is the name. The seeded example is oresmd://ir/USD?type=series&instrument=discount&quote=rate&curve_id=USD-SOFR-FOMC ((marketdata_market_series_fomc_populate.sql)). There is no instrument_type::curve in code: the only instrument_type enum 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 by curve_id in the URI today, not by an instrument type.
  • Points: the pillar-to-value map. These are market_observation rows. The point identity is the oresmd_uri; the observation table has no point_id column (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_series carries derivation_kind and derivation_config_id/version; observation_lineage carries, per point, derivation_config_id/version, source_as_of and source_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.refdata as ir_curve_bootstrap_config (family role, discount curve for a projection, interpolation, day count, tenor convention, split tenor) plus ir_curve_bootstrap_pillar rows. Its output_series_id and 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.

  1. Keep the series as the identity, add a build header, keep the points as observations. A curve_build row names the output series, the reference_date, the build_kind, the recipe (config id + version) and the source_as_of; the points stay market_observation rows whose observation_datetime is the reference date and whose oresmd_uri is 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 what republish() already writes, so no data has to move.
  2. A curve_point table beside curve_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.
  3. A curve entity distinct from market_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_code and .end_tenor_code are undeclared soft references to refdata_tenors: no foreign key, and the only database check validates curve_role_code, not the tenors. The engine resolves a pillar by end_tenor_code -> tenors_by_code -> resolution(convention) -> anchor + offset [+ schedule steps] -> date (tenor_resolution.cpp:151-222), and the end tenor doubles as the point_id the 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_uri carries the term coordinate (datum::term: period, date, fx_tenor or continuation), and the oresmd term field 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_code is stored but read by no consumer logic; and dataset_family_role=FUNDING/PROJECTION is the one role vocabulary that is live, while refdata's curve_role is hard-coded in the engine and oresmd's curve_role is 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_handler checks no permission and no curve-snapshot code 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_SPLINE is recognised by the engine and throws; IMM_ROLL is superseded and throws.

7.4.9. Recommended next steps

  1. Agree the journey catalogue above with the owner (J08 of the extraction standard), then run the marketdata journey extraction, reading the legacy CurveBuilderWorkbench from git show af67db3062^:projects/ores.qt/....
  2. 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.
  3. File the curve-snapshot permission defect and the tenor-validation defect as captures; neither waits on the representation decision.
  4. 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 cold observation_lineage annex, 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_of returns 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 parse
    

    The 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_of stays for the generic reader; read_curve is 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.get is 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_uri once 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_seconds and bucket_count (5 buckets of 1800s by 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.buckets is a vector<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 DISTINCT over 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_pillar carries start_tenor_code, end_tenor_code and sequence_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, FLY and 25BF are 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_observation is no_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_delete trigger 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

  1. A composite object is first-class: identifiable and of a kind (term structure, surface, grid), with its axes named, before any point is read.
  2. 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.
  3. 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.
  4. Every axis value is a typed, validated code with a vocabulary, so ATM, 25RR and 25BF are canonical and a component can be named.
  5. 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.
  6. Evolution is a first-class read: one bounded request returns the aligned matrix of instants by nodes, with the coordinate labels and the values.
  7. Shape change is explicit: the read reports nodes added, dropped and persistent against the declared shape.
  8. The single-instant read, the slice read and the evolution read share one definition of the object, so they cannot disagree.
  9. Every node's value carries its provenance per instant, so the view can explain a change.
  10. 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.
  11. A manual point is not silently overwritten by a later automatic write; the precedence between the two is explicit.
  12. 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.

  1. 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.
  2. 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_tenors and 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 capture A6472918. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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_lineage or 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.
  8. 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.
  9. Guard the curve-snapshot reads (capture D9258D1F). Closed before this list became tasks. The handler checks marketdata::curve_snapshots:read and the permission is seeded, see What the tree has moved since the earlier passes below.
  10. Only then decide the curve_builds table, 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-85 and :121-125 check marketdata::curve_snapshots:read, and projects/ores.sql/populate/iam/iam_permissions_populate.sql:1519 seeds it. The D9258D1F defect 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::curve and to_curve_key() no longer exist. The only instrument_type is ORE's MarketDatum list (ore_types.hpp:38-84), which has no curve member. The generated grammar that held them was retired, so the open question that assumed them is moot.
  • curve_republish_service.cpp has moved. The story's citations (:182, :224, :296, :299) are from d02032a18c. Today the stamp is :171-187, the bootstrap() call is :256, the observation write is :324-333 with the insert at :363, and the lineage write is :344-359 with 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

  1. How does a curve read its points? Today read_as_of returns an array of market_observation objects whose value is text and whose coordinate is inside a URI (market_observation_repository.cpp:418-451). Decision: add the read_curve projection 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 a vector<bootstrapped_point>, so the kernel is one parse away from the projection.
  2. An entity or a shaped series? Decision: a shaped series plus the cold point annex, not a curve_point table. 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=).
  3. 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 in series_subclass (18 seeded codes at refdata_series_subclass_codes_populate.sql:43-96, including yield and volatility) 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 a curve enum is not needed.
  4. What consumes a curve? Nothing in production. The only caller of curve_republish_service is its own wire handler (curve_republish_handler.hpp:67-145, registered at application.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, and curve_health_checker has 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.
  5. Does the oresmd curve vocabulary become load-bearing? Moot. instrument_type::curve and to_curve_key() were deleted with the generated grammar; the URI is the identity. No enum is revived.
  6. Is IR_CURVE_BOOTSTRAP the whole story? No. CRM_DERIVATION is 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).
  7. 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), and market_series.derivation_config_id is 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.
  8. How does a curve relate to feed_binding and observation_lineage? feed_binding maps 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, and republish() already writes those into source_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

  1. Keep the series as the identity; add no curve, build or point table.
  2. Put the point's source kind on the existing cold annex; quoted is the absence of a row.
  3. Reuse the annex's record provenance for a manual point's author, time and reason; add no fields for them.
  4. 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.
  5. Build the aligned evolution read first; it is the first consumer and the first user of the point source kind.
  6. Correct the joins record: the curve-to-points hop is soft, not a hard FK.
  7. 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_build is 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 carries derived or manual, 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 operations group, 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 curve vocabulary.
  • 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.xml and reading curves.csv back. 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_build and curve_point tables. 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 curve vocabulary become load-bearing here? instrument_type::curve is dead and to_curve_key() is gated on type=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_BOOTSTRAP the whole story? CRM_DERIVATION is 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_binding and observation_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 under external/ore/. Start at OREData/ored/configuration/curveconfig.hpp for what a curve config is, and QuantExt/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.

Emacs 29.3 (Org mode 9.6.15)