diff --git a/CHANGELOG-versions.md b/CHANGELOG-versions.md
index 3962e697..71c6b362 100644
--- a/CHANGELOG-versions.md
+++ b/CHANGELOG-versions.md
@@ -1,5 +1,6 @@
| Version | Dates | Commits | Highlights |
+| v2.22.0 | '26 Jul 15 | 3 | - Python 3.14 support. All three Docker images (API, UI, Flight) build on python:3.14-slim and 3.14 joined the CI matrix; snowflake-connector-python and pyarrow were raised to versions that ship cp314 wheels, since the locked ones had none and the slim image failed building them from source.
- sqlglot 30. Constraint moved >=26,<27 to >=30,<31. sqlglot 30 renamed the Select args colliding with Python keywords and made exp.Expr the common base class; the OBSQL translator and pgwire subquery flattening were updated to match. No behavior change, but installs pinning sqlglot 26 must move. pyarrow widened to <26 and structlog to <27.
- OSI converter roundtrip fidelity (osi-orionbelt 0.1.1). Metrics whose SQL referenced physical codes rather than display names were dropped on the return trip; colliding dimension names silently overwrote each other; fields with spaces in the display name emitted invalid OBML with no LOSSY warning; and validate_osi crashed on malformed input instead of reporting schema errors.
|
| v2.21.1 | '26 Jul 13 | 1 | - Arrow Flight SQL result cache restored. Flight's cache read/write called a result-codec API removed in 2.20.0, so every Flight query silently missed the cache and never stored a result; Flight now uses the current data-only codec and shares one entry per compiled query with REST and pgwire.
- Flight cache hits keep the advertised schema. Empty / all-null results no longer stream null-typed columns on a hit: Flight preserves the exact Arrow schema on write and casts a hit back to the schema advertised in FlightInfo.
- Cache key + TTL derivation shared across surfaces via a single layer-clean service module, replacing three copies that could drift (internal refactor, no behavior change).
|
| v2.21.0 | '26 Jul 07 | 3 | - Cross-session content-addressed model cache. Identical OBML loaded into different sessions now compiles once and is shared under a stable content-derived model_id, instead of each session recompiling its own copy under a random id. In admin-curated mode the curated model is compiled once for the process and every user session references it; shared models are refcounted and evicted once no session uses them. Because model_id is part of the result-cache key, identical model plus SQL now shares result-cache entries across sessions too.
- Single-model shortcut resolution scoped to protected sessions. In admin-curated single-model mode the top-level shortcut endpoints ignore transient user sessions, so a duplicated per-session copy of the curated model no longer triggers a spurious "multiple models loaded across sessions" error.
|
| v2.20.0 | '26 Jul 06 | 3 | - Ontology Graph rendered from the OBSL ontology. The UI graph is now a rendering of the exported RDF (single source of truth), so it and the ontology can't drift; synthesized row-count measures appear in it, and an Export Onto button downloads the Turtle. New toolbar: rotate-left/right controls and a clearer PNG export.
- New ontology predicates. obsl:anchoredTo (grain-anchored counts, a new measure source form) and obsl:referencesColumn (columns an expression measure reads, distinct from declared-columns[] obsl:sourceColumn). Ontology, SHACL, and spec updated together; exported graphs stay SHACL-valid.
- Cache stores row data + column schema only. The response envelope (sql, explain, timing, cached, columns) is rebuilt fresh per request, so a cache hit reports the cache read time (not the stale DB time) on every surface and empty/all-null column types survive. format=arrow now returns a length-prefixed frame (JSON envelope + gzip'd Arrow data); JSON/TSV unchanged.
|
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5bd0497..c8105c64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,24 @@
All notable changes to OrionBelt Semantic Layer are documented here.
+## [2.22.0] - 2026-07-15
+
+### Added
+
+- **Python 3.14 support.** All three Docker images (API, UI, Flight) now build on `python:3.14-slim`, and 3.14 joined the CI test matrix alongside 3.12 and 3.13. Reaching it required `snowflake-connector-python` 4.3.0 -> 4.6.0 and `pyarrow` 19.0.1 -> 25.0.0: the previously locked versions had no cp314 wheels, so the slim image tried to build them from source and failed.
+
+### Changed
+
+- **sqlglot 30.** The constraint moved from `>=26.0,<27.0` to `>=30.0,<31.0`. sqlglot 30 renamed the `Select` args that collide with Python keywords (`from` -> `from_`, `with` -> `with_`) and made `exp.Expr` the common base class, so the OBSQL translator (`compiler/sql_translator.py`) and the pgwire federation-subquery flattening (`pgwire/router.py`) were updated to match. No behavior change, but installs pinning sqlglot 26 will need to move.
+- **Dependency constraints widened**: `pyarrow` `>=16.0,<20.0` -> `>=16.0,<26.0` (the old cap had no version-specific rationale and blocked the cp314 wheels the 3.14 images need) and `structlog` `>=25.1,<26.0` -> `>=25.1,<27.0`.
+
+### Fixed
+
+- **OSI converter dropped metrics whose SQL referenced physical codes** (`osi-orionbelt` 0.1.1). `OBMLtoOSI` emits metric SQL against the physical code (e.g. `SUM(fact_orders.amount)`), but `OSItoOBML` resolved references by OSI dataset/field name only, so any metric over a data object whose code differed from its display name was dropped on the return trip. References now resolve by both name and physical code, including quoted physical identifiers (a Snowflake/Databricks `"net_amount"` field expression or a `WH.PUBLIC."fact_orders"` source table) that previously fell through to a LOSSY unconverted metric.
+- **OSI converter silently overwrote colliding dimension names** (`osi-orionbelt` 0.1.1). `_extract_dimensions` keyed dimensions by bare field name, so the same field name in two datasets (`Orders.date`, `Invoices.date`) meant the second silently replaced the first. The later one is now qualified with its data object and a warning is recorded.
+- **OSI converter emitted invalid OBML for fields with spaces in the display name** (`osi-orionbelt` 0.1.1). Resolving a physical code to a display name containing a space spliced `Orders.Net Amount` into the intermediate SQL, which the downstream parsers split on the space, emitting `{[Orders].[Net]} Amount` with no LOSSY warning. Resolved names that are not bare SQL words are now bracket-quoted, and the `dataset.column` parsers accept and unquote bracketed identifiers.
+- **`validate_osi` raised `AttributeError` on malformed input** (`osi-orionbelt` 0.1.1). Its semantic loops now guard against datasets and fields that are not lists of dicts, returning schema errors instead of crashing.
+
## [2.21.1] - 2026-07-13
### Fixed
diff --git a/README.md b/README.md
index 88fe1e89..ce652115 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
[](https://colab.research.google.com/github/ralforion/orionbelt-semantic-layer/blob/main/examples/quickstart_colab.ipynb)
[](https://github.com/ralforion/orionbelt-semantic-layer)
-[](https://github.com/ralforion/orionbelt-semantic-layer/releases)
+[](https://github.com/ralforion/orionbelt-semantic-layer/releases)
[](https://pypi.org/project/orionbelt-semantic-layer/)
[](https://www.python.org/downloads/)
[](https://github.com/ralforion/orionbelt-semantic-layer/blob/main/LICENSE)
@@ -242,7 +242,7 @@ Open [http://localhost:8080/docs](http://localhost:8080/docs) to explore the API
# docker-compose.yml
services:
api:
- image: ralforion/orionbelt-semantic-layer-api:2.21.1
+ image: ralforion/orionbelt-semantic-layer-api:2.22.0
ports: ["8080:8080"]
env_file: .env
volumes:
@@ -251,7 +251,7 @@ services:
MODEL_FILES: /app/models/my-model.obml.yml
ui:
- image: ralforion/orionbelt-semantic-layer-ui:2.21.1
+ image: ralforion/orionbelt-semantic-layer-ui:2.22.0
ports: ["7860:7860"]
environment:
API_BASE_URL: http://api:8080
@@ -267,7 +267,7 @@ See [`.env.template`](.env.template) for the full environment variable reference
> - `API_SERVER_HOST` is already `0.0.0.0` inside the container — no override needed.
> - MCP via stdio does not work in Docker. Use the [MCP HTTP client](https://github.com/ralforion/orionbelt-semantic-layer-mcp) for containerized deployments.
> - Mount models to `/app/models` (or any path) and set `MODEL_FILES` (comma-separated paths) to pre-load on startup.
-> - For production, pin a version tag (`:2.21.1`) rather than `:latest`.
+> - For production, pin a version tag (`:2.22.0`) rather than `:latest`.
### Claude Desktop / MCP
diff --git a/integrations/chatgpt-custom-gpt/openapi-gpt-action.yaml b/integrations/chatgpt-custom-gpt/openapi-gpt-action.yaml
index 9e8341a5..8f6ef613 100644
--- a/integrations/chatgpt-custom-gpt/openapi-gpt-action.yaml
+++ b/integrations/chatgpt-custom-gpt/openapi-gpt-action.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OrionBelt Semantic Layer
- version: 2.21.1
+ version: 2.22.0
description: >
Compile YAML semantic models (OBML) as analytical SQL across 8 database dialects.
This API runs in single-model mode with a pre-loaded semantic model.
diff --git a/mkdocs.yml b/mkdocs.yml
index a4d92eda..9e40edc0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -9,7 +9,7 @@ dev_addr: 127.0.0.1:8080
extra:
obml_version: "1.0"
- project_version: "2.21.1"
+ project_version: "2.22.0"
analytics:
provider: google
property: G-X19K4GX3EX
diff --git a/packages/osi-orionbelt/pyproject.toml b/packages/osi-orionbelt/pyproject.toml
index 27f6a315..f02f1f23 100644
--- a/packages/osi-orionbelt/pyproject.toml
+++ b/packages/osi-orionbelt/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "osi-orionbelt"
-version = "0.1.0"
+version = "0.1.1"
description = "Bidirectional OBML <-> OSI (Open Semantic Interchange) converter for OrionBelt semantic models"
license = { text = "Apache-2.0" }
readme = "README.md"
diff --git a/packages/osi-orionbelt/src/osi_orionbelt/__init__.py b/packages/osi-orionbelt/src/osi_orionbelt/__init__.py
index 9bccf42c..1506c4a8 100644
--- a/packages/osi-orionbelt/src/osi_orionbelt/__init__.py
+++ b/packages/osi-orionbelt/src/osi_orionbelt/__init__.py
@@ -27,7 +27,7 @@
validate_osi_ontology,
)
-__version__ = "0.1.0"
+__version__ = "0.1.1"
__all__ = [
"OBMLtoOSI",
diff --git a/pyproject.toml b/pyproject.toml
index b5c050bf..6360e408 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "orionbelt-semantic-layer"
-version = "2.21.1"
+version = "2.22.0"
description = "OrionBelt Semantic Layer - Compiles and executes YAML semantic models as analytical SQL"
license = {text = "BSL-1.1"}
authors = [{name = "Ralf Becher, RALFORION d.o.o.", email = "info@ralforion.com"}]
diff --git a/src/orionbelt/__init__.py b/src/orionbelt/__init__.py
index 13d5e8e7..3e95a8d4 100644
--- a/src/orionbelt/__init__.py
+++ b/src/orionbelt/__init__.py
@@ -1,3 +1,3 @@
"""OrionBelt Semantic Layer — Compiles and executes YAML semantic models as analytical SQL."""
-__version__ = "2.21.1"
+__version__ = "2.22.0"
diff --git a/tests/unit/test_version_consistency.py b/tests/unit/test_version_consistency.py
new file mode 100644
index 00000000..b87103d9
--- /dev/null
+++ b/tests/unit/test_version_consistency.py
@@ -0,0 +1,40 @@
+"""Every package's exported ``__version__`` must match its pyproject version.
+
+A published wheel self-reports through ``.__version__``, so a version bump
+that updates pyproject.toml but not the module constant ships a wheel whose
+metadata and exported version disagree. This has happened: osi-orionbelt 0.1.1
+was tagged while the module still exported 0.1.0.
+
+Both packages are checked, because osi-orionbelt is versioned independently of
+orionbelt-semantic-layer and is easy to miss when grepping for the main
+package's version.
+"""
+
+from __future__ import annotations
+
+import tomllib
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).parents[2]
+
+PACKAGES = [
+ ("orionbelt", REPO_ROOT / "pyproject.toml"),
+ ("osi_orionbelt", REPO_ROOT / "packages" / "osi-orionbelt" / "pyproject.toml"),
+]
+
+
+def _pyproject_version(pyproject: Path) -> str:
+ with pyproject.open("rb") as fh:
+ return str(tomllib.load(fh)["project"]["version"])
+
+
+@pytest.mark.parametrize(("module_name", "pyproject"), PACKAGES)
+def test_exported_version_matches_pyproject(module_name: str, pyproject: Path) -> None:
+ module = pytest.importorskip(module_name)
+ assert module.__version__ == _pyproject_version(pyproject), (
+ f"{module_name}.__version__ is {module.__version__!r} but "
+ f"{pyproject.relative_to(REPO_ROOT)} declares "
+ f"{_pyproject_version(pyproject)!r}. Bump both."
+ )
diff --git a/uv.lock b/uv.lock
index 358c98cb..c71ecc84 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2285,7 +2285,7 @@ wheels = [
[[package]]
name = "orionbelt-semantic-layer"
-version = "2.21.1"
+version = "2.22.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
@@ -2546,7 +2546,7 @@ wheels = [
[[package]]
name = "osi-orionbelt"
-version = "0.1.0"
+version = "0.1.1"
source = { editable = "packages/osi-orionbelt" }
dependencies = [
{ name = "jsonschema" },