DQ Bundle Publication Pipeline
Table of Contents
- 1. Summary
- 2. Detail
- 2.1. Pipeline overview
- 2.2. Stage 1 — Bundle and artefact-type metadata
- 2.3. Stage 2 — Populating the artefact table
- 2.4. Stage 3 — Bundle orchestration (workflow engine)
- 2.5. Stage 4 — Publish-from-DQ (target-service, hand-written)
- 2.6. Stage 5 — Librarian UI
- 2.7. What has no codegen support today
- 2.8. Worked example:
report_definitionsin therisk_managementbundle - 2.9. Codegen value assessment
- 3. See also
1. Summary
A bundle is a named, ordered collection of DQ datasets
(ores_dq_dataset_bundles_tbl + ..._bundle_members_tbl) that a user
installs into a party/tenant in one action from the librarian UI. Each
member dataset has an artefact type (ores_dq_artefact_types_tbl)
that names the NATS subject of the target service's publish endpoint.
Publishing a bundle starts a bundle_publish_workflow that fans out one
step per member dataset, each step invoking a SECURITY DEFINER
ores_<service>_publish_<entity>_from_dq_fn owned by the target
service — this is the one sanctioned exception to strict service-table
isolation (DQ never writes another service's tables directly). Codegen
already covers the schema side of a new artefact type — the bundle,
junction and artefact-type rows, and the entity's own shadow staging
table (ores_dq_<plural>_artefact_tbl) are all generated. The staging
table comes from the model of the entity it stages, in the component
that owns the published rows, which opts in with
:ores.sql.schema.domain_entity_artefact_create.enabled: true; the one
way to do this is DQ artefacts.
Populating the shadow table is also codegen-generic when the source is FPML
coding-scheme data (via fpml_parser.py); other external sources need
a bespoke per-source parser, and computed data (e.g. synthetic
configs) needs bespoke value computation. What is never
codegen-generated today, for any artefact type, is the target
service's publish_..._from_dq_fn SQL function plus its NATS handler
registration, and any publisher UI config page.
2. Detail
2.1. Pipeline overview
┌─────────────────────────────────────────────────────────────────────┐
│ Librarian UI (went with the removed desktop client) │
│ user picks a bundle, fills per-artefact-type params, clicks Apply │
│ → dq.v1.ops.publish_bundle {bundle_code, target_tenant_id, params} │
└───────────────────────────┬─────────────────────────────────────────┘
│ NATS request
┌───────────────────────────▼─────────────────────────────────────────┐
│ ores.dq.core (publication_service / publication_handler) │
│ loads bundle members in display_order from │
│ ores_dq_dataset_bundle_members_tbl + ores_dq_artefact_types_tbl │
│ → builds bundle_publish_workflow_request │
│ → starts "bundle_publish_workflow" via the workflow engine │
└───────────────────────────┬─────────────────────────────────────────┘
│ one workflow step per dataset
┌───────────────────────────▼─────────────────────────────────────────┐
│ ores.workflow engine │
│ dispatches each step's command_subject in display_order, │
│ halts the saga on the first step failure (no compensation) │
└───────────────────────────┬─────────────────────────────────────────┘
│ <service>.v1.<entity>.publish-from-dq
┌───────────────────────────▼─────────────────────────────────────────┐
│ Target service (ores.refdata.core / ores.assets.core / ...) │
│ publish_from_dq_handler → ores_<service>_publish_<entity>_from_dq_fn│
│ (SECURITY DEFINER: reads ores_dq_<plural>_artefact_tbl, │
│ writes only ores_<service>_* tables) │
└─────────────────────────────────────────────────────────────────────┘
2.2. Stage 1 — Bundle and artefact-type metadata
ores_dq_dataset_bundles_tbl— one row per bundle (code, name, description). Codegen entity model:projects/ores.dq/modeling/ores.dq.dataset_bundle.org.ores_dq_dataset_bundle_members_tbl— junction: which datasets belong to a bundle, and in whatdisplay_order. Codegen junction model:projects/ores.dq/modeling/ores.dq.dataset_bundle_member_junction.org.ores_dq_artefact_types_tbl— lookup table mapping an artefact-type code (e.g.gleif.lei_counterparties.small) to itsartefact_table(source DQ table),target_table, andtarget_subject(the NATS subject the workflow step will call). Codegen entity model:projects/ores.dq/modeling/ores.dq.artefact_type.org.ores_dq_<plural>_artefact_tbl— the shadow staging table for one artefact type: the entity's columns, plusdataset_idandtenant_idbookkeeping, and no PK/FK constraints (a row can be incomplete or reference an entity that doesn't exist yet in the target service). It is generated from the model of the entity it stages, in the component that owns the published rows, which opts in with:ores.sql.schema.domain_entity_artefact_create.enabled: trueand adds an* Artefact columnssection only where the staging shape differs from the entity — e.g.ores.reporting.report_definitiongeneratesores_dq_report_definitions_artefact_tbl(projects/ores.sql/create/dq/dq_report_definitions_artefact_create.sql). A staging table for another component's entity gets no DQ model, no DQ C++ code, no shell commands and no TypeScript; a terminal one, which publishes nowhere and is therefore the entity's only store, is DQ's own, model and all. DQ artefacts states the rule and the checklist.
So four of the five metadata/schema artefacts are codegen-generated today — bundle, junction, artefact type, and the per-entity shadow artefact table. What is not generated is the data that fills the shadow table (next) and the publish function that drains it (Stage 4).
2.3. Stage 2 — Populating the artefact table
Once the shadow table exists, it still needs rows. This splits into two genuinely different cases, not one:
2.3.1. FPML coding-scheme data — generic, codegen'd
Entities backed by an FPML "coding scheme" (a fixed, externally-defined
value domain — see Coding Schemes) go through
projects/ores.codegen/src/fpml_parser.py, a generic parser (input
format: OASIS Genericode XML) that is not written per entity — one tool
handles asset-classes, account-types, person-roles,
non-iso-currencies, and every other FPML-sourced entity via a
directory/filename-pattern lookup table
(ENTITY_FILE_PATTERNS). It emits both the entity's JSON model and
the populate SQL, driven by three archetypes: sql_dataset_refdata.mustache
(one dataset row per scheme), sql_populate_refdata.mustache (INSERTs
into the artefact staging table), and
sql_populate_function_refdata.mustache (the function that copies
artefact rows into the production table). Example output:
projects/ores.sql/populate/fpml/fpml_asset_class_artefact_populate.sql
(AUTO-GENERATED FILE). This is real, reusable codegen support for
populate scripts — it just only applies when the source data is
FPML-shaped.
2.3.2. Other external sources — bespoke, hand-written
Non-FPML external sources (GLEIF LEI CSVs, crypto exchange lists, ISO
code lists) don't fit the Genericode XML shape, so each has its own
one-off Python script (e.g. lei_generate_metadata_sql.py and
crypto_generate_metadata_sql.py under projects/ores.codegen/src/,
iso_generate_metadata_sql.py under external/iso/) that parses that
one source's native format and emits SQL INSERT statements directly
(not via the sql_populate_refdata archetype). This part genuinely is
hand-written per source, and is expected to stay that way — the parsing
logic is inherently source-format-specific.
2.3.3. Computed/synthetic data — neither case
Data that isn't sourced from an external file at all (e.g. synthetic
FX spot configs, whose values come from calibration/calculation, not
import) fits neither path directly. It can still reuse the populate
script skeleton (idempotent dataset registration + artefact-table
insert, the same shape sql_dataset_refdata=/=sql_populate_refdata
produce) even though the FPML parser itself doesn't apply — the value
computation is what's irreducibly bespoke, not the surrounding SQL
scaffolding.
2.4. Stage 3 — Bundle orchestration (workflow engine)
publication_service::publish_bundle (or the equivalent bundle/dataset
handler path, see ores.dq.core/src/messaging/publication_handler.hpp
and dataset_handler.hpp) loads the bundle's members in
display_order, builds a bundle_publish_workflow_request (one
bundle_publish_workflow_dataset per member, carrying dataset_id,
target_subject, mode, params_json), and starts a
bundle_publish_workflow instance.
The workflow definition itself
(projects/ores.dq.api/include/ores.dq.api/workflow/bundle_publish_workflow.hpp,
register_bundle_publish_workflow) builds one workflow_step_def per
dataset: command_subject is the dataset's target_subject, the
command payload is a publish_from_dq_command (dataset_id,
tenant_id, mode, params_json), and there is no compensation step
(publish functions are idempotent upserts, so a failed saga simply
halts and leaves already-published datasets in place). Progress is
tracked via the standard workflow step-completed events; a final audit
row is written to ores_dq_bundle_publications_tbl
(projects/ores.sql/create/dq/dq_bundle_publication_create.sql).
2.5. Stage 4 — Publish-from-DQ (target-service, hand-written)
Each target service exposes a NATS subject
<service>.v1.<entity>.publish-from-dq, handled by a
publish_from_dq_handler that calls a SECURITY DEFINER SQL function
ores_<service>_publish_<entity>_from_dq_fn defined in that service's
own SQL tree, one file per component. There are 57 functions across
seven components, and they are very unevenly spread:
refdata_publish_from_dq_create.sql holds 47 of them, analytics,
iam, reporting and synthetic hold 2 each, and assets and
marketdata hold 1 each. This is the one sanctioned
exception to strict service-table isolation: the function reads
ores_dq_<plural>_artefact_tbl (cross-service read, DDL-owner
privilege) and writes only to tables owned by its own service — DQ
itself never gets DML on another service's tables. See
doc/plans/2026-05-14-dq-publish-pattern.org for the full rationale
and migration history behind this rule.
Nothing about this stage is codegen-generated — each
publish_..._from_dq_fn is hand-written SQL, and each
publish_from_dq_handler registration is hand-written C++, repeated
per entity.
2.6. Stage 5 — Librarian UI
DataLibrarianWindow, in the desktop client since removed, listed
bundles ("The Stacks") and their member datasets ("The Registry").
Selecting Publish opened PublishBundleWizard, which walked
the user through per-artefact-type parameter pages before sending
dq.v1.ops.publish_bundle. Parameter pages are hand-coded per artefact
type today (e.g. LeiPartyConfigPage for LEI's root_lei etc.) — the
originally-intended generic publication_params_schema (a jsonb
column on ores_dq_artefact_types_tbl that would let the wizard render
config UI from a schema instead of bespoke pages) is not yet
implemented; see
doc/agile/product_backlog/next/add_publication_params_schema_to_artefact_types.org.
2.7. What has no codegen support today
Adding a brand-new bundle/artefact type currently means hand-writing, per artefact type:
- The populate/ETL script (stage 2) — unless the source is FPML
coding-scheme data, in which case
fpml_parser.pyalready handles it generically. - The target service's
publish_..._from_dq_fnSQL function (stage 4). - The
publish_from_dq_handlerregistration in the target service's registrar (stage 4). - A wizard config page in
PublishBundleWizardif the artefact type needs user-supplied parameters (stage 5).
The bundle row, junction row, artefact-type row, and the shadow
artefact table itself (stage 1) are all already backed by codegen —
and so is the populate step for FPML-shaped sources. The publish
function is the one gap with no codegen path today, for any source:
any effort to add a new bundle type at scale (e.g. a
synthetic-market-data bundle) needs either to accept the hand-written
cost of one more publish_..._from_dq_fn=/handler pair, or to extend
=ores.codegen with a reusable "publish-from-dq" profile before
proceeding — the 57 existing functions are structurally identical
enough (read artefact table, honor mode, upsert into one target
table) to make a generic profile plausible.
2.8. Worked example: report_definitions in the risk_management bundle
Every file the pipeline touches for one concrete artefact type, in pipeline order:
- Bundle row —
projects/ores.sql/populate/dq/dq_dataset_bundle_populate.sqlregisters therisk_managementbundle itself (ores_dq_dataset_bundles_upsert_fn('risk_management', 'Risk Management', ...)) — the thing a user picks in the Librarian. - Bundle membership —
projects/ores.sql/populate/dq/dq_dataset_bundle_member_populate.sqlmaps theore.report_definitionsdataset into therisk_managementbundle atdisplay_order 40(after business_units/portfolios/books at 10/20/30) — this row is what stage 3's workflow builder iterates to build one step. - Artefact-type registration —
projects/ores.sql/populate/dq/dq_artefact_types_populate.sqlregisters thereport_definitionsartefact type, pointingartefact_table → dq_report_definitions_artefact_tbl,target_table → reporting_report_definitions_tbl, andtarget_subject → reporting.v1.ops.publish_report_definitions_from_dq— this row is the glue the workflow step uses to know which NATS subject to call (stage 1). - Staging declaration — the owning entity model at #7 opts in with
:ores.sql.schema.domain_entity_artefact_create.enabled: true. This artefact needs no* Artefact columnssection, because its staging shape is the entity's own columns. - Generated shadow table —
projects/ores.sql/create/dq/dq_report_definitions_artefact_create.sql(auto-generated from #7) createsores_dq_report_definitions_artefact_tbl— no PK/FK constraints, justdataset_id=/=tenant_idbookkeeping plus the entity's columns (stage 1). - Dataset registration + seed data —
projects/ores.sql/populate/reporting/reporting_report_definitions_populate.sqlregisters theore.report_definitionsdataset (ores_dq_datasets_upsert_fn) and inserts the 28 standard ORE analytics report rows straight intoores_dq_report_definitions_artefact_tbl(stage 2). This is a "seed-time inversion" per DQ Publish Pattern: reporting's own populate script writes into a DQ-owned table, but only at DDL-owner setup time, not at runtime — distinct from the publish-time cross-service write in #9 below. - Target entity codegen model —
projects/ores.reporting/modeling/ores.reporting.report_definition.org(ores.codegen.entity) is the "real"report_definitiondomain type. It is what #8's target table is generated from, what #9 ultimately writes rows shaped like, and — since the DQ artefact migration — what #5 is generated from too. - Target table —
projects/ores.sql/create/reporting/reporting_report_definitions_create.sqlcreatesores_reporting_report_definitions_tbl, the real, constrained, party-scoped table that end users query. - Publish function —
projects/ores.sql/create/reporting/reporting_publish_from_dq_create.sqldefinesores_reporting_publish_report_definitions_from_dq_fn(SECURITY DEFINER): readsores_dq_report_definitions_artefact_tbl, honorsmode(upsert/insert_only/replace_all), writes only intoores_reporting_report_definitions_tbl(stage 4). Hand-written — the gap identified above. - NATS handler —
projects/ores.reporting/core/src/messaging/publish_from_dq_handler.cpp(declared in the matching.hpp) receives apublish_from_dq_commandand calls #9's SQL function (stage 4). Hand-written per service, not per entity, but still not generated. - Registrar wiring —
projects/ores.reporting/core/src/messaging/registrar.cppsubscribes #10 on the subjectreporting.v1.ops.publish_report_definitions_from_dqnamed in #3 (stage 4). - Bundle-publish orchestration (generic, not report_definitions-specific)
—
projects/ores.dq/core/include/ores.dq.core/messaging/publication_handler.hppturns the bundle's member list into abundle_publish_workflow_requestand starts the workflow (stage 3);projects/ores.dq/api/include/ores.dq.api/workflow/bundle_publish_workflow.hppis the workflow definition that turns that request into one step per dataset, dispatching to #3'starget_subject(i.e. #11's subscription). - Publish entry point (generic — no bespoke wizard page needed here,
unlike LEI's
root_leiparameters). The UI lists therisk_managementbundle for the user to select, then sendsdq.v1.ops.publish_bundlewithbundle_code = "risk_management"on Apply. No per-entity code is needed, sincereport_definitionsneeds no publish-time parameters (stage 5). The screen that did this,DataLibrarianWindow, went with the desktop client, so nothing sends the request today.
2.9. Codegen value assessment
Rating each of the 13 worked-example files/artefacts by where codegen already pays off, where it plausibly could, and where it structurally can't — this is the reference to consult before adding a new type to the bundle system.
| # | Artefact | Status today | Verdict |
|---|---|---|---|
| 5 | Shadow artefact table SQL | Generated | Already solved — no work needed |
| 7,8 | Target entity model + table | Generated (ordinary entity codegen) | Already solved — orthogonal to bundling |
| 12 | Workflow orchestration | Generated infra, generic | Already solved — zero marginal cost per new type |
| 13 | Librarian UI shell (list bundle, Apply) | Generic | Already solved, except a bespoke wizard param page when publish needs user input |
| 4 | Staging-shape declaration | Solved — declared on the owning model | Already solved by the DQ artefact migration; the hand-authored lookup_entity shadow models are gone |
| 3 | Artefact-type registration row | Hand-written SQL insert | Mechanical — target_subject is 100% derivable from <service>.<entity> by naming convention |
| 9 | Publish-from-dq SQL function | Hand-written (57 near-identical copies) | Highest-value codegen target — read artefact table, honor mode, upsert by natural key; structurally identical across all 57 instances |
| 10,11 | NATS handler + registrar wiring | Hand-written per service | Mechanical, same shape as ordinary entity-messaging codegen — needs a "publish_from_dq" facet |
| 1,2 | Bundle row + membership row | Hand-written one-liner SQL calls | Cheap to template but low ROI as a standalone archetype — better as a scripted step of an "add to bundle" helper |
| 6 | Seed/populate data | Split: generic for FPML sources, hand-written otherwise | FPML coding-scheme data is fully codegen'd via fpml_parser.py (generic, not per-entity); other external sources (LEI, crypto, ISO) need bespoke per-source parsers; computed/synthetic data (our FX case) can reuse the populate-script skeleton but the value computation is irreducibly bespoke |
Recommendation: build a single new ores.codegen facet — provisionally
publish_from_dq — that hangs off an entity's existing codegen model
and, from one declaration, generates items #3, #9, #10 and #11. Item #4
is no longer part of this: the DQ artefact migration already moved the
staging-shape declaration onto the owning model, so there is nothing
left to derive there. Once the facet exists, adding a new type to the
bundle system reduces to: model the entity normally (already required
for any entity) → enable the publish_from_dq facet → hand-write only
the bundle/membership rows (one-liners) and the actual seed data.
3. See also
- The data tiers — when a table belongs here at all, and which of the other delivery mechanisms takes the tables this pipeline does not.
- DQ Publish Pattern — Architecture and Decision — the design rationale and migration history behind the publish-from-dq rule.
- ores.dq.dataset_bundle — codegen entity model for bundles.
- ores.dq.artefact_type — codegen entity model for artefact types.
- DQ artefacts — the one way to add an artefact, and the retired patterns this page's history produced.
- Codegen facet: generate the DQ publish-from-dq pattern — the story that would carry the recommendation above.
- Coding Schemes — what makes a value domain "FPML-shaped" and
therefore eligible for the generic
fpml_parser.pypopulate path. - Eventing Pipeline — the analogous walkthrough for the change-notification pipeline; this doc follows the same style.