Skip to content

feat(relay): add governed SDMX read binding - #702

Closed
jeremi wants to merge 16 commits into
mainfrom
codex/relay-v2-sdmx
Closed

feat(relay): add governed SDMX read binding#702
jeremi wants to merge 16 commits into
mainfrom
codex/relay-v2-sdmx

Conversation

@jeremi

@jeremi jeremi commented Aug 10, 2026

Copy link
Copy Markdown
Member

Summary

Adds a governed SDMX REST read binding to Relay V2 as a stacked change on #699.

  • keeps the authored statistical dataset format-neutral: ordered dimensions, one explicit time dimension, one explicit measure, optional attributes, classifications, vocabularies, access rules, and bounds
  • makes SDMX an explicit optional binding with deterministic defaults and small reviewed identity overrides
  • serves bounded SDMX-JSON 2.1, SDMX-CSV 2.1, dataflow, and DSD reads under /sdmx/v2
  • preserves Relay authorization, verified purpose and row binding, quota, audit release gating, metadata visibility, cache, source, and package invariants
  • adds schema-only relayctl inspect --statistical-view scaffolding that requires explicit view, time, and measure columns and never samples values or guesses codelists
  • adds an executable labour-statistics adopter project, generated-contract drift checks, security traceability, and real-router acceptance coverage

This does not add dynamic aggregation, arbitrary operators, schema or availability behavior, maintenance/history APIs, streaming, a generic storage abstraction, or registryctl compatibility work. Schema and availability paths are reserved with a value-free 501. This is standards-alignment work, not a conformance or certification claim.

Security and correctness

  • filters are closed to compiled exact dimension values and bounded time ranges
  • governed codelists are validated before SQL and source rows are checked before release
  • duplicate observation tuples fail closed across the complete authorized filtered result, including pages after the duplicate group and empty pages beyond the result
  • protected structure existence is non-enumerable, protected/live responses are no-store, and public OpenAPI excludes protected datasets
  • durable attempt audit gates source execution and terminal audit gates held response bytes
  • package closure captures statistical vocabularies and governance sidecars; startup requires issuer and quota configuration where applicable

A fresh clean-context staff/security review completed with GO and no P0/P1 findings.

Verification

  • cargo fmt --all -- --check
  • cargo check --locked -p registry-relay-v2 --all-targets --features tooling
  • cargo check --locked -p registry-relayctl --all-targets
  • cargo clippy --locked -p registry-relay-v2 --all-targets --features tooling -- -D warnings
  • cargo clippy --locked -p registry-relayctl --all-targets -- -D warnings
  • cargo test --locked -p registry-relay-v2 --features tooling
  • cargo test --locked -p registry-relayctl
  • products/relay-v2/scripts/check-generated.sh
  • products/relay-v2/scripts/check-contracts.sh
  • products/relay-v2/scripts/test-http.sh
  • python3 .github/scripts/test_ci_changes.py

Post-rebase verification reran all eight real-router SDMX tests and the full Relay V2 product/adopter contract gate.

jeremi added 15 commits August 10, 2026 03:04
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi force-pushed the codex/relay-v2-sdmx branch from 2b54ade to 7a97a71 Compare August 10, 2026 09:01
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi force-pushed the codex/relay-v2-sdmx branch from 7a97a71 to 8460923 Compare August 10, 2026 09:47
@jeremi
jeremi marked this pull request as ready for review August 10, 2026 09:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8460923274

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2107 to +2112
if matches!(access, CompiledAccess::Public) {
for (name, visibility) in [
("resources", self.contract.metadata_visibility.resources),
("semantics", self.contract.metadata_visibility.semantics),
] {
if visibility != Visibility::Public {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject operator-only metadata for protected datasets

When a statistical dataset is protected, this public-only guard allows metadataVisibility.resources or metadataVisibility.semantics to be operator-only, even though an authorized data caller receives the dataset name, description, components, concepts, and codelist identities from structure_response and the embedded SDMX data structure. Such a contract therefore compiles while mounting metadata explicitly configured to remain operator-only; apply the same operator-only rejection used by validate_metadata_closure for protected consultation operations.

Useful? React with 👍 / 👎.

Comment on lines +1632 to +1636
fn codelist_urn(dataflow: &CompiledStatisticalDataset, component_id: &str) -> String {
format!(
"urn:sdmx:org.sdmx.infomodel.codelist.Codelist={}:CL_{}({})",
dataflow.sdmx.agency_id, component_id, dataflow.sdmx.version
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make generated codelist identities unique per vocabulary

If two datasets use the same agency/version and share a common component ID such as REF_AREA, this generates the identical CL_REF_AREA URN even when the components reference different governed vocabulary files. The compiler only checks dataflow and DSD endpoint tuples, so this ordinary multi-dataset configuration compiles and publishes two incompatible vocabularies under one SDMX identity, causing consumers to interpret one DSD using the other's codes; include a dataset-specific identity component or reject conflicting generated codelist identities.

Useful? React with 👍 / 👎.

Comment on lines +735 to +739
if (is_range && text.contains(',')) || (!is_range && text.contains('+')) {
return Err(ProblemCode::StatisticalFeatureUnsupported);
}
let separator = if is_range { '+' } else { ',' };
for term in text.split(separator) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve delimiter characters in exact dimension values

A reviewed code or string value may contain , or + because governed codelist validation only requires non-empty unique strings, but this parser always interprets commas as value separators and rejects plus signs outside time ranges. URL-encoding cannot escape them because decoding happens before this split, so values such as A,B are silently queried as two other values and A+B is unqueryable; either reject reserved delimiters during compilation or introduce an escaping-aware parser.

Useful? React with 👍 / 👎.

Comment on lines +1547 to +1550
.map(|(position, component)| {
json!({
"id": component.id,
"position": position,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Start DSD dimension positions at one

SDMX data-structure dimension positions are one-based, but enumerate() is emitted directly here, producing ordinary positions 0, 1, ... and a time position of dimensions.len(). Every generated DSD with an ordinary dimension therefore contains an invalid zero position and shifts the declared key order by one, so validating SDMX consumers may reject it or interpret the dimension order incorrectly; emit position + 1 and place the time dimension at dimensions.len() + 1.

Useful? React with 👍 / 👎.

Comment on lines +941 to +944
let id = to_authoring_id(&column.name);
if attribute_columns.contains(&column.name) {
attributes.push((
id.clone(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject colliding statistical starter identifiers

Distinct SQLite columns can normalize to the same authoring ID—for example, ref_area and ref-area both become refArea—but both entries are appended without a uniqueness check. OrderedMap::from_entries then serializes duplicate YAML mapping keys, while the strict contract parser explicitly rejects duplicate keys, so relayctl inspect --statistical-view can generate a starter that cannot be consumed by the authoring workflow; report the collision or derive unique deterministic IDs before writing the file.

Useful? React with 👍 / 👎.

Comment on lines +940 to +947
"application/vnd.sdmx.data+json" if version.is_none_or(|value| value == "2.1.0") => {
Some(DataRepresentation::Json)
}
"application/vnd.sdmx.data+csv" if version.is_none_or(|value| value == "2.1.0") => {
Some(DataRepresentation::Csv)
}
"application/json" | "*/*" if version.is_none() => Some(DataRepresentation::Json),
"text/csv" if version.is_none() => Some(DataRepresentation::Csv),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor application wildcard media ranges

A client sending the valid media range Accept: application/* should match either vendor SDMX application representation, but this negotiation recognizes only exact media types, application/json, and */*, returning 406 instead. The structure negotiator has the same omission, so generic HTTP clients that advertise an application wildcard cannot consume either new SDMX surface; treat application/* as a supported range while retaining the existing quality and version selection.

Useful? React with 👍 / 👎.

Comment on lines +849 to +852
"operationId": format!("{}.sdmx.{}", dataflow.id, kind.replace('/', ".")),
"x-registry-family": "aggregate-data",
"x-registry-capability-operation": dataflow.operation_identifier(),
"security": security,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the required scope on structure operations

For a protected dataset, the runtime authorizes both structure routes with the dataset's exact compiled scope, but these generated OpenAPI operations only declare generic bearer security. The corresponding data operation emits x-registry-required-scope, so the full deployment contract currently omits the only machine-readable indication of which scope a client needs for dataflow and DSD reads; add the same required-scope extension to each protected structure operation.

Useful? React with 👍 / 👎.

Comment on lines +1418 to +1420
fn value_document(value: &SqlValue) -> Value {
let value = csv_value(Some(value)).unwrap_or_default();
json!({"id": value, "name": value})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize non-identifier dimension values with value

This renders every dimension and coded-attribute value as an SDMX id, but supported decimal and free-string dimensions can contain values such as 1.5 or North East that do not satisfy the SDMX identifier pattern. Those contracts and source rows pass compilation and runtime validation, yet the resulting SDMX-JSON fails its advertised 2.1 schema; emit the typed value form for non-identifier component values and validate any values that are deliberately emitted as code IDs.

Useful? React with 👍 / 👎.

Comment on lines +451 to +458
for (suffix, operator) in [("lower", ">="), ("upper", "<=")] {
let bound_present = format!("dimension_{time_index}_{suffix}_present");
let bound = format!("dimension_{time_index}_{suffix}");
parameters.push(parameter(&bound_present));
parameters.push(parameter(&bound));
predicates.push(format!(
"(:{bound_present} = 0 OR {} {operator} :{bound})",
quote_identifier(&dataflow.time.source_column)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare time bounds chronologically

The accepted time grammar permits annual, quarterly, monthly, and daily periods in the same dataset, but these predicates compare their raw text in SQLite. Mixed-granularity ranges therefore return incorrect observations—for example, 2024-10 sorts before 2024-Q2 even though October is later—while the request is reported as successful; normalize periods to comparable temporal bounds or require and enforce one homogeneous period format before using lexical comparisons.

Useful? React with 👍 / 👎.

Base automatically changed from codex/relay-v2 to main August 10, 2026 18:52
@jeremi jeremi closed this Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant