Datus extensions to Apache Ossie (vendor spec)¶
- Status: draft, in use by Datus (dosi-engine + the Datus generation agent).
- Spec version:
datus-ext/1(the major series) - Current version: 1.10 — see §2.1 for what each
minor added and how a version bump is decided. Any engine can report the
version it implements:
dosi info,GET /v1/capabilities, ordosi_engine.DATUS_EXT_VERSION. - Scope: semantics that Apache Ossie (formerly
OSI) core spec
0.2.0.dev0cannot yet express, carried inside the Ossie-sanctionedcustom_extensionsfield so a document stays fully Ossie-valid and every non-Datus consumer ignores them.
For a task-oriented walkthrough with before/after examples, see the Datus extensions guide. This page is the normative contract.
Separately from model semantics, dosi-engine's connection configuration also speaks Datus: profiles use the agent.yml
datasources:vocabulary and a fullagent.ymlcan be passed as--connectionsdirectly — see connectors.md.
This is a private vendor extension: it does not need to be accepted by the Ossie working group to be usable. Where an extension generalizes a gap that belongs in the core spec (time granularity, cumulative windows), the long-term home is an Ossie RFC; this document is the interim vehicle Datus ships against today.
1. Why this is safe against Ossie¶
Ossie's schema is strict (additionalProperties: false everywhere), so we cannot
add bare fields like relationship.join_type. But the spec provides a
first-class escape hatch on every major object:
custom_extensions is present on SemanticModel, Dataset, Field, Relationship,
and Metric (see crates/dosi-model/src/spec.rs). The upstream ossie validator
accepts it, so a document carrying Datus extensions passes the validate-osi
gate unchanged. A consumer that doesn't understand vendor_name: DATUS simply
skips it — the model still means exactly what pure Ossie says it means. The Datus
extensions only refine engine behavior at points Ossie leaves undefined; they
never change what a metric or dimension is.
2. The envelope¶
A Datus extension is one custom_extensions entry:
vendor_name: canonically the uppercase constant"DATUS"(matching the Ossie spec's uppercase enum style, e.g.ANSI_SQL). Matching is case-insensitive; the historical lowercasedatuskeeps working.data: a JSON string (per the Ossie specdatais a string, not an object) decoding to a single JSON object — the payload. At most oneDATUSentry per object. Two envelope keys are reserved:vandrequires.
Consumption rules (dosi-engine):
- Absent → the documented Ossie default (below). Extensions are purely additive; a model with none behaves exactly as today.
- Unknown payload keys are ignored, not rejected — so a newer Datus agent can
emit keys an older engine doesn't read without breaking it (forward-compat).
The exception is
requires, below. - Unknown
vendor_name(anything butDATUS, compared case-insensitively) is ignored. - A malformed
DATUSpayload (not JSON, or a known key with the wrong type) is a structured error (invalid_datus_extension), not a silent default — a Datus-authored document is held to the Datus contract. Compilation still continues with the default, so one pass reports every problem in the model.
v — the declared version¶
v is MAJOR.MINOR. The string form is canonical ("v": "1.1"); a JSON
number is accepted for convenience ("v": 1.1, and the bare integer "v": 1
meaning 1.0).
A JSON number can only carry minors 0–9.
1.10and1.1are the samef64, so a number cannot express minor 10; a number with two or more fractional digits is rejected outright with a message telling you to use the string form. Once the current minor reaches 10, write"v"as a string always.
v is optional and omitting it is always safe — an unversioned payload
behaves exactly as it did before the version gate existed, and produces no
diagnostic. The version is declared per custom_extensions entry, not per
document: a model-level v does not become a default for its datasets, fields,
relationships, or metrics.
What stamping a version buys you, given the engine's own version:
| Situation | Engine's response | Code |
|---|---|---|
v absent or null |
silent — behaves as before the gate | — |
v malformed (bad string, wrong JSON type, number with ≥2 decimals) |
error | invalid_datus_extension |
v below 1.0, or a major newer than the engine's |
error | unsupported_datus_ext_version |
| minor newer than the engine's | warning naming the keys that were dropped; everything the engine knows still applies | datus_ext_version_ahead |
a key newer than the v you declared |
warning; the key is honored anyway | datus_ext_key_newer_than_declared |
| minor older than the engine's, keys all within it | silent | — |
A future major is the one blanket rejection: a major bump means at least one key changed meaning, and an engine that predates the change has no way to know which — reading the payload anyway would produce quietly wrong numbers. Minors are additive by construction, so they only ever warn.
requires — keys that must not be dropped¶
requires is an array of key names the producer declares must not be silently
ignored. A consumer that does not implement a listed key raises
datus_ext_key_required naming it, instead of dropping it.
This exists because a consumer cannot judge the danger of a key it has never
heard of. "Unknown keys are ignored" is the right default for a key like
join_type, where ignoring it falls back to a documented behavior — but the
wrong default for a key like semi_additive, where ignoring it means summing an
end-of-period balance and returning a number that is simply wrong. The producer
knows which is which; requires is how it says so.
Emit requires for every key whose ignore-impact is silent (see §2.1) and
for nothing else: letting an older engine degrade is better than refusing to
serve it. dosi info / GET /v1/capabilities report each key's impact.
In Datus mode, a non-null silent key omitted from requires emits
datus_ext_requires_missing, even when v is absent. The model still compiles;
add the named key to requires to protect older consumers.
2.1 Versioning policy¶
Every key this engine reads is registered with the version that introduced it
and what it costs a consumer to ignore it. The registry lives in
crates/dosi-compiler/src/ext.rs and is the source of truth: the version gate,
the basic-mode warning text, dosi info and GET /v1/capabilities all read it
at runtime, so none of them can drift from it. The change log in §7 is written
by hand against that registry.
Ignore-impact levels, ordered by increasing danger — note that failing loudly ranks safer than quietly returning different numbers:
| Impact | Meaning |
|---|---|
inert |
Presentation metadata the engine never consumed. Results are identical. |
degraded |
No wrong numbers — the request fails loudly instead (a structured error, or a query name that stops resolving). |
documented |
Numbers change, but predictably and in a documented direction (LEFT vs INNER, NULL vs 0). |
silent |
Numbers are wrong and the caller cannot tell. Must be listed under requires. |
Two shipped keys are silent: measure (D-MEASURE, 1.4) and time
(D-FORMAT, 1.6) — see the change log in §7. Every other key is degraded or
documented, so basic mode remains coherent for models that use neither. A
model that uses one must list it under requires, which is what stops a
datus-mode engine that does not implement the key (an older Dosi, another
vendor's reader of the envelope) from silently computing a different number:
it fails at the version gate with datus_ext_key_required instead. Basic mode
is a different case — it never reads the payload at all, requires included,
and only warns ignored_vendor_extension; a model with a silent key should
not be run in basic mode.
When to bump:
- MINOR — purely additive: a new key, or a new accepted value for an
existing key that no old payload could have used. Old payloads keep working
unchanged; a new payload on an old engine gets
datus_ext_version_aheadand loses only the new key. A new key whose impact issilentis still a minor bump, but the producer must list it underrequires. - MAJOR — only when an existing key changes meaning, is renamed, or is
withdrawn. Every payload declaring an older
vis then rejected per changed key, with an instruction for updating the YAML. - Neither — engine-internal work that changes no key's contract. A version with no key behind it would make the registry's invariants vacuous.
3. Extension catalog¶
D-JOIN — relationship join type¶
Where: a Relationship's custom_extensions.
Payload key: join_type, enum "left" | "inner".
Default (absent): "left".
Controls how a fan-out branch follows this relationship (many→one) when it joins the "one" side in to reach a dimension or an off-base measure:
"left"(default): keep rows on the many side that have no match — orphan fact rows survive and land in the NULL group of any dimension drawn from the missing side. Reconciliation / audit semantics."inner": drop unmatched many-side rows. Attribution semantics — a measure is only counted where the entity it attributes to actually exists.
This makes the INNER-vs-LEFT choice an explicit property of the relationship
instead of a hard-coded generator default (see docs/semantics.md S-JOIN).
relationships:
- name: sales_to_regions
from: sales
to: regions
from_columns: [region_id]
to_columns: [region_id]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.0", "join_type": "inner"}'
D-CONFORM — conformed relationships between facts¶
Where: a Relationship's custom_extensions.
Payload key: cardinality, enum "many_to_one" | "many_to_many".
Default (absent): "many_to_one" — the ordinary foreign-key edge the join
walk follows.
Impact if ignored: silent — list it in requires.
Two fact tables often share dimensions — a date, a platform — without either
being the many side of the other: a login row does not belong to a payment
row. Strict OSI can relate them only through a shared dimension table, so a
cross-fact metric like payers per active player by date has no date to
group by (unconformed_dimension). many_to_many declares the pairing
directly: each from_columns / to_columns pair is the same dimension
spelled on each side.
relationships:
- name: login_payment_conformed
from: netbar_login
to: netbar_payment
from_columns: [stat_date, platform_id]
to_columns: [pay_date, platform_id]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.8", "requires": ["cardinality"], "cardinality": "many_to_many"}'
A conformed edge is never joined row to row — that would fan every
SUM / COUNT out. Each fact aggregates alone, projects its own spelling of
the paired column under the query's output name, and the branches merge on
that name exactly as S-FAN-3 merges any two branches:
WITH m0 AS (SELECT netbar_payment.pay_date AS stat_date, COUNT(DISTINCT netbar_payment.player_id) AS payers
FROM main.netbar_payment GROUP BY netbar_payment.pay_date),
m1 AS (SELECT netbar_login.stat_date AS stat_date, COUNT(DISTINCT netbar_login.player_id) AS users
FROM main.netbar_login GROUP BY netbar_login.stat_date)
SELECT COALESCE(m0.stat_date, m1.stat_date) AS stat_date, CAST(m0.payers AS DOUBLE) / m1.users AS pay_rate
FROM m0 FULL OUTER JOIN m1 ON m0.stat_date = m1.stat_date OR m0.stat_date IS NULL AND m1.stat_date IS NULL
What the pairing makes possible (fixture fixtures/datus_conform/):
- Either spelling works in
group_by,whereandtime_range.dimension, for metrics on either fact —netbar_payment.pay_dategroups a login-only metric too; the output column keeps the spelling the query used. metric_timeresolves per branch when the facts' primary time dimensions are paired: each branch filters and groups its own date.- A grain rolls up per branch (
stat_date:month): each fact truncates its own column and the merge key is the truncated value. Facts stored at different grains may be paired; the coarser side's D-GRAIN floor applies. - Transitive: payment ↔ login and login ↔ register pair payment with register too — three facts, one date dimension.
- Only the paired columns are shared. Grouping a cross-fact metric by an
unpaired column is
unconformed_dimension; filtering on one isno_join_path, with a hint that the filter cannot be applied to one side alone (make the filtered member a D-DERIVEfiltermetric instead). A conformed edge cannot be named in a relationship path (invalid_relationship_path) — there is no row on the other side to reach. - S-FAN-6 still holds: a count inside a ratio reads NULL for a date that has rows on one side only; only a bare count metric fills 0.
- A pair may be stored differently on each side — a native
DATEon one fact, a D-FORMAT%Y%m%dstring on the other. A pairing says the columns are one dimension, not one encoding, so every bound the planner moves across is folded into the target column's encoding: the single time range, each tagged range'sCASEgate, and the scan predicate those gates OR together. The one thing that cannot cross is awhere_sqlcomparison — D-FORMAT refuses an encoded column there (the literal would not be in its encoding), and naming the pair's native side is refused the same way rather than silently rewritten (unsupported_filter, naming both spellings). Usetime_rangewith that dimension instead. - A window metric may be grouped by either spelling as long as its axis
is one column. A window over one fact asked for under the other fact's name
is that fact's own series, ordered by its own column, under the name the
query used. A window metric whose own members span two paired facts has no
single column to order by and stays
not_implemented.
Declared once, checked once: paired columns must agree on
dimension.is_time and, when both declare a datatype, on the type; a
dataset contributes at most one column to a conformed class; join_type is
invalid on a many_to_many edge (invalid_datus_extension). Model
validation still warns relationship_target_not_unique on the edge — correct
under strict OSI, and the one signal a consumer that ignores the key gets.
Why silent: an engine that drops the key sees an ordinary many→one edge and
joins the facts row to row. A COUNT DISTINCT compose then returns
plausible numbers (every login without a same-day payment falls into the
NULL bucket); a SUM across the join is refused as fan_out_risk only when
the dimension sits on the other fact, and double-counts otherwise. Listing
cardinality in requires makes a datus-mode engine without it fail at the
version gate instead. Basic mode (§5) is that degradation by design.
Design: ../design/d-conformed-edges.md; cases: ../design/d-conform-e2e-cases.md, and ../design/d-lod.md (4.5, the D cases in 9.3) for alignment on a level of detail.
D-FILL — metric null-fill for missing merge groups¶
Where: a Metric's custom_extensions.
Payload key: fill_nulls_with, a JSON number.
Default (absent): engine default — a bare COUNT metric fills 0
(docs/semantics.md S-FAN-6); every other metric keeps NULL.
An explicit fill_nulls_with takes precedence over the S-FAN-6 count default
and applies to any metric kind (SUM, ratio, expression), COALESCE-ing the
metric's output to the given number in both merge and single-branch plans.
After a multi-branch merge, a group present in only some branches projects
NULL for the metrics evaluated in the missing branch. fill_nulls_with: <n>
COALESCEs this metric's value to <n> (typically 0) so a group with no
contributing rows reports a concrete number instead of a blank.
Scope guard (unchanged from S-FAN-6): fill is applied to the metric's output,
never to a measure embedded in a ratio/expression — a count denominator must
not become a literal 0 divisor. For a ratio, fill the ratio, and the engine
still evaluates it null-safely.
Under a window stage the count default lands one layer lower — on the merged
measure column itself, since the stage and everything above it read columns
rather than branch-qualified expressions. Same answer either way: adding a
window metric to a query never moves a count from 0 to NULL.
metrics:
- name: sales_total
expression:
dialects:
- dialect: ANSI_SQL
expression: SUM(sales.amount)
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.0", "fill_nulls_with": 0}'
D-TIME — primary (aggregation) time dimension¶
Where: a Dataset's or a Metric's custom_extensions.
Payload key: time_dimension, a string name reference — on a dataset,
the name of one of its own is_time fields; on a metric, "field" or
"dataset.field".
Default (absent): a dataset with exactly one is_time field gets it as
implicit primary time (this inference reads no extension, so it applies in
basic mode too); zero or several is_time fields → no primary time.
Declares which time column is a table's/metric's business time axis — the column the engine uses when a query asks for a time range or grain without naming a column. Resolution per metric (S-TIME-5):
- the metric's own
time_dimension(highest precedence); - else the unique primary time among the metric's datasets (explicit dataset
time_dimension, else the single-is_timeinference).
A metric-level bare "field" resolves against the metric's own datasets first
(time-field names like etl_dt recur across datasets), widening to the whole
model only if none of them has the field.
Queries consume this through the reserved name metric_time
(--group-by metric_time:month, --time-dimension metric_time), and through
the time-range fallback: a range with no named dimension and no time item in
the group-by filters each metric's primary time — per aggregation branch, on
that branch's own column. A metric with no resolvable primary time yields
no_primary_time_dimension. Metrics on one base with different primary times
are not an error: each time axis gets its own branch over that base, and the
branches merge on the group keys, so each metric returns what it returns
alone.
The reference must exist and have an effective is_time: true (explicit or
from the core datatype default), else
invalid_datus_extension.
datasets:
- name: orders
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.1", "time_dimension": "order_date"}'
fields:
- name: order_date
dimension: { is_time: true }
- name: ship_date
dimension: { is_time: true }
metrics:
- name: shipped_revenue
expression:
dialects: [{ dialect: ANSI_SQL, expression: SUM(orders.amount) }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.1", "time_dimension": "ship_date"}'
D-GRAIN — native time granularity¶
Where: a Field's custom_extensions (the field must be
dimension.is_time: true, explicitly or from the core datatype default).
Payload key: time_granularity, enum "day" | "week" | "month" |
"quarter" | "year".
Default (absent): inferred from time.format or core datatype: Date
(day); otherwise unknown — any query-time grain may be requested.
Declares the grain the column is stored at (a monthly snapshot's etl_dt,
say). Requesting a strictly finer grain (:day on a month column, via an
explicit group-by item or via metric_time) is a structured grain_too_fine
error instead of silently wrong data. Truncation to the native grain or
coarser is unchanged. Interim vehicle for the upstream RFC's
dimension.time.granularity.
fields:
- name: etl_dt
dimension: { is_time: true }
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.1", "time_granularity": "month"}'
D-FORMAT — time storage format¶
When to configure it¶
Use D-FORMAT when a time field stores dates as strings or integers, such as
'20260907' or 202609. Native DATE/TIMESTAMP fields normally need no
configuration: that is the default when time.format is absent.
time.format describes the stored encoding, not the output display format.
Declare it in a field's DATUS custom_extensions. Its effective is_time must
be true, either explicitly or from the core datatype default.
The declaration describes the result of the field's expression, which may
be a calculation rather than a source column.
Quick start¶
Within a dataset's fields, declare a string date and an integer month as follows.
storage is inferred from datatype: String gives "string", Integer
gives "int". Without datatype, it still defaults to "string". Include
"time" in requires so older
Datus-mode engines that do not support it reject the model instead of ignoring it.
fields:
- name: dt
expression:
dialects: [{dialect: ANSI_SQL, expression: dt}]
datatype: String
dimension: {is_time: true}
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.6", "requires": ["time"], "time": {"format": "%Y%m%d"}}'
- name: etl_month
expression:
dialects: [{dialect: ANSI_SQL, expression: etl_month}]
datatype: Integer
dimension: {is_time: true}
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.6", "requires": ["time"], "time": {"format": "%Y%m", "storage": "int"}}'
For a dataset named events, a query's time-range fragment still uses
YYYY-MM-DD, regardless of the stored encoding. The start is inclusive and
the end exclusive:
Writing filters¶
Use time_range to filter D-FORMAT encoded fields. Its bounds always use
YYYY-MM-DD; the engine converts them to the declared storage encoding.
where_sql rejects any reference to a D-FORMAT encoded field with
unsupported_filter, even when the literal uses the correct encoding.
This also applies to computed dataset fields, references inside functions or
CAST, IS NULL, IN, BETWEEN, and comparisons between fields. Remove the
reference from where_sql and use time_range instead. Conditions that cannot
be expressed by time_range, such as selecting isolated dates or NULL values,
are not supported through where_sql for these fields.
Native temporal declarations (date, datetime, timestamp, timestamp_tz)
remain allowed in where_sql, as do fields without a D-FORMAT encoding.
This restriction applies to query where_sql. D-DERIVE filter and filters
inside metric expressions still require literals in the stored encoding;
they do not convert ISO date literals automatically. For example, a %Y%m%d
string field requires '20260401', not '2026-04-01'. Use
dosi list dimensions to inspect time_format and time_storage.
Configuration reference¶
The time object accepts format, storage, and granularity.
Besides the native type names below, format accepts a continuous, ordered,
fixed-width prefix of %Y%m%d%H%M%S. %Y is a four-digit year; %m, %d,
%H, %M, and %S are two-digit, zero-padded month, day, hour (00–23), minute,
and second. Stop after any component, but do not skip, repeat, or reorder them.
Any fixed literal text may appear between components or as a prefix/suffix:
%Y-%m, %Y年%m月%d日, %Y.%m.%dT%H:%M, and %Y%m%d%H%M%S are valid.
Write %% for a literal percent sign. Unknown directives, variable-width
modifiers, fractional seconds, and timezone directives are not supported.
For example, %Y%d, %d/%m/%Y, %-m, and %Y%m%d%z are rejected.
These combinations extend the existing 1.6 contract without a version bump.
Engines predating this enhancement reject layouts they do not recognize:
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.6","requires":["time"],"time":{"format":"%Y年%m月%d日"}}'
Every stored value must follow the declared layout, including literal text and padding. Bare-column range comparisons require a column collation that orders these fixed-width values chronologically; use an appropriate binary/code-point ordering when necessary. The declaration does not inspect or change collation.
The original six encodings remain supported:
format |
Stored value | Inferred grain | Allowed storage |
|---|---|---|---|
%Y%m%d |
20260907 |
day |
string, int |
%Y-%m-%d |
2026-09-07 |
day |
string |
%Y/%m/%d |
2026/09/07 |
day |
string |
%Y%m |
202609 |
month |
string, int |
%Y |
2026 |
year |
string, int |
%Y-%m-%d %H:%M:%S |
2026-09-07 12:30:00 |
Not inferred | string |
Native temporal declarations do not parse or re-encode the field:
format |
Meaning | Inferred grain |
|---|---|---|
date |
Native date | day |
datetime, timestamp, timestamp_tz |
Native date and time | Not inferred |
Prefer datatype: Date, DateTime, or DateTimeTz for new native temporal
fields; no time.format is needed. The legacy spellings remain supported:
date must agree with Date, datetime and timestamp with DateTime,
and timestamp_tz with DateTimeTz. Native temporal datatypes reject stored
patterns such as %Y%m%d. Time has no supported D-FORMAT encoding.
Native declarations do not accept storage. For encoded fields, an explicit
storage must agree with datatype (String → "string", Integer → "int");
other declared datatypes cannot use patterns. Omitted storage is inferred from
the datatype, or defaults to "string" when datatype is absent. "int" accepts
only uninterrupted component prefixes without literal text (including literal
digits), such as %Y%m%d%H or %Y%m%d%H%M%S. The longest encoding has 14
digits: choose a sufficiently wide integer-valued column, such as BIGINT or
DECIMAL(n,0), for a compact timestamp. A physical DECIMAL(n,0) column holding an
integer date key may declare logical Integer; logical Decimal is not an
integer encoding. storage cannot be declared without format. These checks
use the field expression's result type, not the source column type. Basic mode
ignores the Datus block with a warning, while retaining the core datatype
time-role default.
granularity accepts day, week, month, quarter, or year. It may be
omitted when the format implies it. time.granularity and time_granularity
are equally supported spellings: neither is deprecated, but they must agree
when both are present. A declared grain cannot be finer than the encoding.
A time object may declare only granularity, but cannot be empty.
Year/month/day prefixes infer year/month/day regardless of their literal
text; prefixes including a clock component do not infer a grain. Hours,
minutes, and seconds in storage do not enable sub-day grouping or date-time
time_range bounds: those query features remain unsupported. Parsed output
uses January/day one for omitted calendar components and zero for omitted clock
components; for example, %Y-%m denotes the first day of that month, and
%Y%m%d%H denotes the start of that hour.
Grouping and range boundaries¶
A %Y%m field can be grouped by month, quarter, or year, but not by day or
week: its stored values contain no such detail. Likewise, %Y supports year
grouping. Requesting a finer grain produces grain_too_fine.
Range boundaries must align with the stored periods. For %Y%m,
end: "2026-03-15" produces grain_too_fine: use "2026-03-01" to exclude
March or "2026-04-01" to include it. The error offers both aligned bounds;
the engine does not choose whether to include a partial month. Year encodings
require year boundaries. These checks apply to the query's bounds; scan
expansion for window calculations is handled by the engine.
Selected time dimensions are parsed into date/time values, including when no grain is requested. Do not rely on results retaining the stored text layout. D-FORMAT does not provide an output display-format setting.
Validation and known limits¶
The declaration must match the data. Dosi checks supported formats and
configuration combinations, and — where the field declares core
Field.datatype — that the declared storage and native format agree with it.
It does not inspect warehouse column types or values, so dosi validate
cannot establish that %Y%m%d matches the actual field expression's output.
A wrong format or storage type can cause a warehouse error, NULL groups, or
incorrect filtering without an error. For example, declaring %Y%m%d for
values stored as '2026-09-07' also generates filter literals in the wrong
layout. Check both the expression's type and representative stored values.
ClickHouse uses parseDateTime, whose DateTime result is limited to roughly
1970-01-01 through 2106-02-07. Earlier or later dates, including an SCD2
'99991231' end-date sentinel, are not supported reliably for grouping or
parsed projections. Verified on ClickHouse 24.8.14: parseDateTime rejects
1960, 2107, and 9999 dates with CANNOT_PARSE_DATETIME.
parseDateTime64 is unavailable in that version. The BestEffort alternatives
are not a safe replacement: parseDateTimeBestEffort clamps out-of-range
values, and parseDateTime64BestEffort changes 9999-12-31 to 2299-12-31.
Bare encoded time_range comparisons do not
use this parser, but that does not make an out-of-range grouping safe.
In basic mode, DATUS extensions are ignored with a warning, including
requires. Use Datus mode for fields that depend on encoded-date handling.
Epoch encodings such as epoch_seconds and epoch_millis are not supported.
How it affects SQL¶
Grouping parses an encoded field into a date/time value and truncates it when a coarser grain is requested. At the stored grain, or with no requested grain, the GROUP BY key can remain the stored column and the SELECT expression parses it.
For time_range, the engine instead converts the bounds into the stored
encoding. The examples above generate predicates of these forms:
Keeping parsing functions off the filter column helps warehouses use partition pruning. Actual pruning depends on the warehouse, field expression, and partition definition.
D-DATASET — metric home dataset¶
Where: a Metric's custom_extensions.
Payload key: dataset, a string naming a dataset of the model.
Default (absent): attribution is derived from the metric SQL alone.
A COUNT(*) names no column, so in a multi-dataset model with no other
aggregate pinning a dataset, Ossie leaves its attribution undefined and the
engine errors (count_star_needs_dataset). The hint resolves exactly that
tie. It never overrides SQL-derived attribution — a metric whose aggregates
name columns keeps their datasets regardless of the hint (extensions never
redefine what the SQL says). An unknown dataset name is
invalid_datus_extension.
metrics:
- name: chat_message_count
expression:
dialects: [{ dialect: ANSI_SQL, expression: "COUNT(*)" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.1", "dataset": "chat_record"}'
D-WINDOW — derived window metrics (period-over-period / rolling / cumulative)¶
Where: a Metric's custom_extensions.
Payload key: window, a JSON object — full schema in
window-extension.md.
Default (absent): the metric is its plain base aggregate — no derivation.
Declares a window derivation over the metric's own expression (which must
infer as a single plain aggregate): the three sugars pop / rolling /
cumulative, or the general offset / frame form they desugar to. The
time axis is not declared here — D-WINDOW composes with D-TIME (metric
time_dimension > dataset primary; a missing axis fails at query time with
no_primary_time_dimension). Offsets lower to a calendar-correct shifted-key
self-join with automatic scan expansion + output trim; frames lower to
agg(v) OVER (… ROWS …), with reset adding a DATE_TRUNC partition key
and its own expansion + post-window trim. The remaining query restrictions
(one shared time axis among the metrics that need one; a no-reset running
metric or a partition-global one beside scan-expanding metrics under a
time_range) are structured not_implemented errors; a reset finer than the queried grain is
window_reset_too_fine. D-FILL never applies to derived outputs (NULL means
"no comparable prior period"). Raw window SQL in metric expressions stays
rejected (window_in_metric) — this extension is the only window path.
metrics:
- name: revenue_mom_growth
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(orders.amount)" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": 1, "window": {"type": "pop", "offset": "1 month"}}'
D-DERIVE — derived metrics (filter / compose / lod)¶
Where: a Metric's custom_extensions.
Payload key: derive, a JSON object with a type discriminant — plus
the window key's base field (M2, below).
Default (absent): the metric is self-contained, no structure metadata.
Supported forms: M1 ships filter + compose;
M2 adds window.base and compose-with-window-members; 1.10 adds lod
(D-LOD, documented with the rest of
D-LOD). reagg and cross-dataset filter predicates
are reserved and reject not_implemented with a hint naming their
replacement or milestone.
Declares how a metric derives from other metrics of the same model —
structure Ossie expressions cannot carry. Two degradation tiers:
on the flatten tier (filter, compose over leaves/filter members) the
metric's expression is a legal, semantically equivalent Ossie aggregate —
basic mode computes it and gets the same numbers, and the engine enforces
the equivalence at compile time (derive_expression_mismatch); on the
stage tier (window.base, compose with window members) no flat
expression can express the semantics, so the expression is only the
nearest approximation and basic mode legitimately diverges (with the
ignored-extension warning). Either way, treat the expression as
tool-maintained output, not something to edit by hand.
filter—{"type":"filter","base":<metric>,"where":<predicate>}: a dimensional-filter variant ofbase. The predicate folds into the aggregate argument (CASE WHEN pred THEN arg END;COUNTbases countTHEN 1), never aWHERE— non-matching time buckets keep existing (value NULL), which is what makes the variant safe under ROWS-framed windows. M1 limits the predicate to the base's own dataset (a cross-dataset predicate isnot_implementeduntil M4) andbaseto a plain non-derived single aggregate. The engine records the subset relation (subset_of) in metric metadata.compose—{"type":"compose","expr":<arithmetic>,"fill":<n>}: scalar arithmetic over metric names ("revenue - total_refunds"). Identifiers resolve strictly to metric names — a qualified or unknown name is an error with candidates, never a silent column read. Aggregate calls are rejected (composition never re-aggregates).fillreplaces a member's NULL after branch alignment, before the arithmetic (the cross-fact "missing bucket poisons the sum" fix). The engine detects linear combinations (Σ cᵢ·metricᵢ), records members + coefficients + the leaf-measure decomposition, and fixes the conformed dimension set (dimensions every member branch reaches, under the unique-join-path rule) at compile time —dosi listexposes it, and a query grouping outside it getsunconformed_dimensionnaming the member that misses the dimension. Members sharing no dimension warncompose_no_conformed_dimensionsat compile time. Attribution metadata is labeledexactonly for linear composes over SUM/COUNT-tier measures (a filteredCOUNT(DISTINCT)overlaps its complement — never exact).lod—{"type":"lod","expr":<level of detail>}: the view-relative level-of-detail keywords,avg(include(base, dims…))andexclude(base, dims…), and any compose over one. Unlike the other two families the metric'sexpressionis not an equivalent fallback — it is the nested aggregate the declaration describes, which no engine can execute, so basic mode fails closed rather than computing one level of it. Documented in full with the field-hosted half: D-LOD.
M2 additions:
window.base— thewindowkey (D-WINDOW) accepts"base": <metric>: the window computes over the named metric's series instead of this metric's own expression. The base may be a leaf, a filter metric, a ratio (1.9), a compose — whether its measures sit in one dataset or several — or a compose over another window's output (1.9). A cross-fact base's series only becomes a value after the branch merge, so the stage sits on the merge instead of on one aggregate. Rejected: naming a window metric directly (the inner window would have no stage of its own to sit on — wrap it in a compose, which gives it one). Metadata recordsderive_family: "window", thederive_basechain and the inheritedsubset_of. Windows over a filter base inherit the CASE-WHEN bucket preservation: buckets with no matching rows still exist, so ROWS frames stay calendar-correct and cumulative sums skip the NULLs.
A ratio or compose base is what makes "rank the markets by close rate", "this rate versus last month's" and "am I above the average rate" expressible — none of which a single aggregate can state.
Nested windows (1.9) go one step further: a window reading a compose
over another window's output is a second level, lowered as one more SQL
stage. The scorecard shape needs it — rank the markets within a brand,
then weight the scores within a market — because the two comparisons
partition in different directions and one OVER has one PARTITION BY.
This is not the re-aggregation I-1 bans: the row count is unchanged, each
level recomputes with the query, and the weighted value lands on every
row rather than folding them away. Levels are capped at 3, and past the
first one partition must be declared explicitly — the
query_dimensions default reads clearly on a single stage, but stacked,
"which dims, minus whose exclusions" is no longer answerable from one
declaration. Note what the
window does and does not average: a whole-partition avg over a ratio
base is the unweighted mean of the per-row rates, which is not the
pooled ratio of sums. Both are legitimate and they differ; the window is
the one that says "mean of the rates".
- compose with window members — a compose whose expr references
window metrics runs the arithmetic over the window output
("cumulative AOV" = revenue_cum / order_count_cum: numerator and
denominator each accumulate, then divide). Members may mix window
metrics with leaves; all members share one window stage, so they must
share a time axis (cross-fact window members are rejected). Attribution
is always approximate and there is no leaf-measure decomposition —
window output cannot be served from materialized aggregates.
- filter members in compose — a compose member may be a filter metric
("new_order_count / order_count"); it composes on the flatten tier
exactly like a leaf.
Compose members that are themselves composes are expanded inline at
compile time: the compiled artifact is flat, exactly as if the inner
arithmetic had been written out. That is what lets a formula be stated once
and reused — and, because the outer's expression is checked against the
expansion of the inner's declaration, editing the inner and leaving a
stale copy in a consumer is a derive_expression_mismatch rather than a
number nobody questions.
Four consequences worth knowing before you nest:
- The
expressionis the full expansion. Write out the whole flattened arithmetic, including the parentheses the nesting implies and theCAST(… AS DOUBLE)an inner bare-ratio member compiles to. It is tool-maintained output — regenerate it from the declaration rather than editing it by hand. fillpushes down to the leaves. Each leaf reference takes the innermostfilldeclared on its path, so an outerfill: 0still repairs a missing branch inside a member that declared none. The decision is per reference: one leaf reached through two members that fill it differently keeps both.derive_membersis the flattened leaf list on the flatten tier, with coefficients folded across the levels (quadrupled = doubled × 2overdoubled = revenue × 2reportsrevenueat 4). Conformed dimensions intersect along the DAG, andunconformed_dimensiontherefore names the leaf that misses the dimension, not the intermediate compose.dosi lineagestill draws the edge to the member you wrote.- On the stage tier a compose member stays a member. A window anywhere in
the tree puts the metric on the stage tier, and there inlining would be
wrong: each member is its own series out of the window stage, so dissolving
an intermediate compose into leaves would lose arithmetic the stage never
projects (
cum_aov / revenuewould lose the division and become two window members over a leaf).derive_membersis therefore the DIRECT members there, the planner computes each nested compose as a hidden stage output, and substitution runs innermost-first at lowering. The tier decision reads the whole tree, so a window on an intermediate compose stages everything above it too. - Nesting is capped at 256 member references in one expansion — an inlined member is substituted at every occurrence, so referencing one twice doubles the expansion per level.
Cycles among metric references are metric_reference_cycle with the full
path. Metadata surfaces on every host (dosi list, REST, MCP, Python):
derive_family, derive_base, derive_expr, subset_of, derive_members,
leaf_measures, attribution, conformed_dimensions, and
required_dimensions — the dims a query has to group by, gathered over the
whole chain (window-extension.md#frozen-degrees).
metrics:
- name: net_revenue
expression:
dialects: [{ dialect: ANSI_SQL, expression: "COALESCE(SUM(orders.amount), 0) - COALESCE(SUM(refunds.ref_amount), 0)" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.4", "derive": {"type": "compose", "expr": "revenue - total_refunds", "fill": 0}}'
# Reuses net_revenue; `expression` is the expansion, so a change to
# net_revenue's declaration breaks this metric loudly.
- name: net_revenue_per_order
expression:
dialects: [{ dialect: ANSI_SQL, expression: "(COALESCE(SUM(orders.amount), 0) - COALESCE(SUM(refunds.ref_amount), 0)) / COUNT(orders.order_id)" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.4", "derive": {"type": "compose", "expr": "net_revenue / order_count"}}'
D-MEASURE — explicit measure name¶
Where: a Metric's custom_extensions.
Payload key: measure, {"name": <identifier>}.
Default (absent): the sanitizer-generated stem (S-MEASURE-1).
Names the metric's single synthesized measure explicitly. Measure names are
otherwise derived by sanitizing the aggregate argument ASCII-only, so two
aggregates differing only in non-ASCII content (CASE literals such as the
Greek 'νέο' / 'παλιό' — "new" / "old") collapse to one stem and fail
measure_name_collision — the model cannot compile at all. The explicit
name is a label: dedup still keys on the aggregate signature (an
undeclared textual twin shares the declared name), the name enters the same
collision checks (two explicit names on one signature, or one name on two
signatures, still collide loudly), and it applies only to single-aggregate
metrics. Because measure names surface in result columns and metadata,
ignoring this key silently renames them — emit it under requires so older
engines refuse instead (the first silent-impact key since requires
shipped).
metrics:
- name: new_product_revenue
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(CASE WHEN orders.category = 'νέο' THEN orders.amount END)" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.4", "requires": ["measure"], "measure": {"name": "orders_new_product_amount_sum"}}'
D-PARAM — query-time parameters¶
Where: a Metric's custom_extensions.
Payload key: params, a list of {name, type, default, allowed | min/max, description}.
Default (absent): no parameters — the metric is exactly what it declares.
A metric's window width, offset count, rank bucket count, navigation
position, or a literal in its filter predicate becomes a typed query-time
parameter: one moving_avg(n) instead of r7_revenue / r30_revenue /
r90_revenue, one ltv(n) instead of eight ltvN columns. Parameters fill
typed holes only — never SQL text — so a model validates once for every
legal binding, and a binding can never inject anything.
metrics:
- name: moving_avg
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(sales.amount)" }]
custom_extensions:
- vendor_name: DATUS
data: |
{"v": "1.5",
"params": [{"name": "n", "type": "int", "default": 3, "min": 1, "max": 12,
"description": "trailing window width in buckets of the queried grain"}],
"window": {"type": "rolling", "function": "avg", "periods": {"param": "n"}}}
- name: paid_within_n
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(CASE WHEN base.datediff_pay < 7 THEN base.sum_pay_amount END)" }]
custom_extensions:
- vendor_name: DATUS
data: |
{"v": "1.5",
"params": [{"name": "n", "type": "int", "default": 7, "allowed": [1, 3, 7, 14, 30, 60, 90, 180]}],
"derive": {"type": "filter", "base": "pay_total", "where": "datediff_pay < :n"}}
Declaration. Every parameter has a name ([a-z][a-z0-9_]*, unique
within the metric), a type (int | float | string), a mandatory
default, and a domain — allowed (an explicit value list) or min/max
(a closed range, either side optional); the two are mutually exclusive. The
domain is the governance boundary: it says which definitions this metric is
allowed to take. A string parameter must declare allowed (an unbounded string
parameter would be an arbitrary dimension value) and takes no min/max; an
int/float parameter without any domain compiles with the
datus_param_unbounded warning. default must lie in the domain.
Slots. A parameter is referenced from exactly these places:
| Slot | Where | Syntax | Parameter type |
|---|---|---|---|
offset.count |
window.offset.count (object form; the "1 month" sugar takes no parameter) |
{"param": "n"} |
int, domain ≥ 1 |
frame.preceding |
window.frame.preceding |
{"param": "n"} |
int, domain ≥ 0 |
rolling.periods |
window.periods under type: rolling |
{"param": "n"} |
int, domain ≥ 1 |
rank.buckets |
window.rank.buckets (ntile) |
{"param": "k"} |
int, domain ≥ 1 |
value.n |
window.value.n (nth_value) |
{"param": "k"} |
int, domain ≥ 1 |
filter.literal |
a literal inside a D-DERIVE filter metric's where |
:n (bind syntax) |
int / float / string |
The window slots are integer counts, so they take int parameters whose
domain has a declared lower bound at or above the slot's own minimum; a
filter.literal hole takes any of the three types (region = :r,
amount > :t). One parameter may fill several slots, and one predicate may
hold several parameters (datediff_pay < :n AND platid = :plat). A
parameter-only predicate (:n < 7) is rejected — a filter must still test a
column. :name anywhere else — a metric expression, a query-time
where_sql — is an error, never a silent literal. A parameter no slot
references compiles with datus_param_unused.
The compiled model is instantiated at every parameter's default, so a
model that declares parameters plans exactly like one that does not until a
query binds something. For a filter slot the authored expression must be
that default instantiation (< 7 above): the D-DERIVE equivalence gate holds
the two together, and a bound value re-materializes the same measure a
hand-written literal would produce — paid_within_n at n = 30 and a
hand-written SUM(CASE WHEN datediff_pay < 30 …) share one column of the
base CTE.
Binding. A query binds by name, for every queried metric that declares
the parameter — directly or through its derive members (a compose inherits
its members' parameters, so ltv = paid_within_n / new_user_cnt takes n):
{"metrics": ["new_user_cnt", "ltv"], "group_by": [{"field": "base.register_date"}],
"params": {"n": 30}} // one binding
{"metrics": ["new_user_cnt", "ltv"], "group_by": [{"field": "base.register_date"}],
"params": {"n": [1, 3, 7, 14, 30, 60, 90, 180]}} // a list: one column per value
Omitted parameters take their defaults; a binding that no queried metric
declares is unknown_metric_param (with the declared names as candidates); a
value outside the type or domain — including 7.5 or "7" for an int, or
a list where the parameter is used by attribution — is param_out_of_domain
with a ready-to-send suggested_retry. The one implicit conversion: a
float parameter accepts a JSON integer. Lists expand the metric into one
column per value (several lists take the cartesian product, first declared
parameter outermost), capped at 64 columns per query
(param_expansion_too_large).
Output. A default binding leaves the column named after the metric; a
non-default binding names it {metric}__{param}_{value} (moving_avg__n_6,
ltv__n_30; float 1.5 → _1_5, strings sanitized: o'neil → o_neil),
several parameters in declaration order, and under a list binding every
column is suffixed. The rule exists so two dashboards can never show the same
metric name over different windows — and every response carries the
machine-readable form regardless: outputs, one entry per metric column with
its metric and resolved params (defaults included), in the CLI JSON, the
REST and MCP responses, the Python dicts, explain, and as Arrow field
metadata (dosi.metric, dosi.params).
The sanitizer is ASCII-only, so it cannot always keep a string domain's values
apart: an all-CJK allowed list collapses onto one suffix, and so does
us-east next to us_east. Such a value takes an opaque token instead —
h followed by 8 hex digits of a frozen FNV-1a hash of the value
(by_channel__c_h0898d4b7), widened to 16 on the astronomically unlikely
collision. The token is a function of the value and its declared domain and
nothing else, so it does not move when the query binds a different set, when
allowed is reordered, or when a value is added to it. A value keeps its stem
whenever that stem is unique in the domain and is not itself of the reserved
h<hex> shape, so a readable ASCII domain is named exactly as before. The
model is told at compile time which values went opaque, and with what token,
by the datus_param_opaque_token warning; outputs[].params carries the
value, and dosi query --format text prints a legend under the table.
An order key names a metric (by its model name) or a group-by item (by its
output name, or by the spelling the query used — sales.sale_date:month as
well as sale_date__month), never a column the engine assigned. A metric key
resolves while the metric is one column, suffix and all; a metric expanded by
a list binding is unknown_order_key, because there is no spelling that picks
one of its columns. To rank by one binding, bind that one value — the retry on
the error is the params fragment that does it.
Attribution binds one value per parameter (a list is refused with a
single-value retry) and echoes the resolved bindings in
comparison_metadata.params.
Catalog. dosi list metrics, GET /v1/metrics and MCP list_metrics
carry each metric's params (declaration plus the slots it fills);
describe_metric adds param_schema, a JSON Schema for the params map
(allowed → enum, min/max → minimum/maximum) — an agent reads it,
then binds.
Compatibility. Under --osi-basic the D-WINDOW / D-DERIVE hosts are
ignored, so params is inert with them (one ignored_vendor_extension
warning) and any query-time params is unknown_metric_param. An engine
older than 1.5 rejects {"param": …} in a window slot structurally (the
field is an integer there) and the "v": "1.5" envelope at the version gate
— which is the guard that matters for :name in a filter, since a 1.4 engine
would otherwise render the bind marker into SQL. Note that MetricQuery
tolerates unknown fields: a 1.4 server silently drops a client's
params and answers with the defaults. Check GET /v1/capabilities (or
dosi info) for params before relying on it.
D-DIM — which fields are offered as dimensions¶
Where: a Field's custom_extensions.
Payload key: is_dimension, a boolean.
Default (absent): the engine infers the field's role (see below).
Ossie types every non-time field as a regular dimension, so a model cannot say
that a column is a measurement (unit_cost), an identifier
(c_customer_id), free text, or PII. Grouping by such a column is not a
structural error — the SQL runs, the numbers are right, and the analysis is
meaningless. is_dimension is how the model says so once, at modelling time,
instead of every caller re-deciding it at every call.
It is a listing signal, not a permission:
| Written | Effect |
|---|---|
| absent | the engine infers (below) |
false |
the field is not recommended: list_dimensions marks it is_dimension: false, and attribution does not offer it as a candidate. Naming it in group_by still works. |
true |
the field is recommended even where inference would exclude it — the way to group a numeric column into buckets |
Because it never reaches the planner, ignoring the key changes no number; it
only costs a caller a worse shortlist (documented impact).
datasets:
- name: store_sales
fields:
- name: ss_quantity # a measurement no metric aggregates
expression: { dialects: [{ dialect: ANSI_SQL, expression: ss_quantity }] }
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.7", "is_dimension": false}'
- name: i_current_price # numeric, but analyzed in price bands
expression: { dialects: [{ dialect: ANSI_SQL, expression: i_current_price }] }
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.7", "is_dimension": true}'
Inference when the key is absent. The engine uses only model structure —
it never probes the warehouse (a COUNT(DISTINCT …) on a high-cardinality
column is not a cost the engine may impose). A field is not offered when it
is a primary-key member, a single-column unique key of the metric's own home
dataset, a column named in a relationship's join pair, or a column some metric
measures — the value read by a SUM/AVG/STDDEV*/VAR*. Everything else
is offered, and list_dimensions reports which rule applied, as source.
Three aggregates that mention a column without measuring it, and therefore leave it a dimension:
COUNT—COUNT(DISTINCT region)("how many active regions") is an ordinary metric over an ordinary dimension. This holds for the metric being listed too: the answer for a field does not depend on which metric you ask about.MIN/MAX— they order strings and dates perfectly well;MIN(activity_code)picks one identifier, it does not measure it.- A condition inside an aggregate —
SUM(CASE WHEN is_instant THEN 1 ELSE 0 END)counts rows where a flag holds; the flag stays a dimension. Only theTHEN/ELSEvalues are measured. A D-DERIVE filter folds into exactly this shape, so it behaves the same way.
Inference cannot see a business key that is neither a declared key nor a relationship column, a numeric column no metric reads, free text, or PII — those are exactly the fields worth declaring.
D-LOD — fixed level-of-detail fields¶
Where: a Field's custom_extensions.
Payload key: lod, an object {"expr": "fixed(base, dims…)"} — one
level-of-detail expression, the same grammar a metric's
derive: {type: "lod"} uses. base names a metric
that is a single plain aggregate; the dimensions are fields of the field's
own dataset, and naming none is the table-scoped FIXED — Tableau's
{MAX([date])}, one value broadcast onto every row.
Default (absent): the field is an ordinary row-level attribute.
A lod field's value is not a stored column: it is base evaluated over the
dataset grouped by dims — Tableau's {FIXED [dims]: base}. That makes the
result of an aggregation usable as an axis: how many users were active
exactly N days, where N is itself COUNT(DISTINCT date) per user. Queries
carry no new syntax; the field is groupable, filterable and aggregatable like
any other, and the engine plants the rollup stage — a two-stage plan when the
view and its filters are functions of the rollup keys, the
broadcast below otherwise.
fields:
- name: active_day
# the aggregate verbatim — see "the expression is not a sentinel" below
expression:
dialects: [{ dialect: ANSI_SQL, expression: "COUNT(DISTINCT event_date)" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.10", "requires": ["lod"],
"lod": {"expr": "fixed(active_days, sessions.user_id)"}}'
One grammar, two carriers. fixed is the same word here and in a
metric's derive, parsed by the same parser; what differs is what each
carrier accepts. A field takes only fixed, and no outer aggregate — its
value is one per key, so there is nothing to collapse. The view-relative
keywords are the metric's, because their grain is a function of the query.
| Form | Field | Metric |
|---|---|---|
fixed(base, dims…) |
yes — the value is a column of the row | yes — the value attaches to the view's rows |
include(…) / exclude(…) |
no — a field has no "view" | yes |
outer aggregate agg(…) |
no | include requires one; exclude and fixed forbid one |
// how many users seen on channel 2 were active exactly N days in January
{"metrics": ["user_num"],
"group_by": ["sessions.active_day"],
"where": "sessions.channel_id = 2",
"time_range": {"start": "2025-01-01", "end": "2025-02-01"}}
SELECT f.active_day, COUNT(DISTINCT sessions.user_id) AS user_num
FROM main.lod_session AS sessions
JOIN (SELECT user_id, COUNT(DISTINCT event_date) AS active_day
FROM main.lod_session
WHERE event_date >= DATE '2025-01-01' AND event_date < DATE '2025-02-01' -- time_range: context
GROUP BY user_id) AS f
ON sessions.user_id IS NOT DISTINCT FROM f.user_id
WHERE sessions.channel_id = 2 -- where: dimension filter
AND sessions.event_date >= DATE '2025-01-01' AND sessions.event_date < DATE '2025-02-01'
GROUP BY f.active_day
The January window scopes the per-user count; the channel filter selects rows after it, so a user active on two channels in January is a 2-day user on channel 2 too — the order Tableau evaluates FIXED in (see filter placement).
Re-aggregating a pinned value is spelled exactly once: an ordinary metric that
aggregates the field (AVG(sessions.daily_users) over a daily_users field keyed
by event_date is the average daily actives; group it by metric_time:month
for the monthly series). There is no separate keyword for it.
Where a filter applies: Tableau's order of operations¶
active_day is a function of the rows it is computed over, so where a
predicate applies decides the number, not just its coarseness. The layers
follow Tableau's order of operations — context filters, then FIXED, then the
filter shelf:
time_range(andtime_ranges) andcontext_filterare the context layer. The analysis window passes into every rollup's input, so the query above counts each user's days in January. This is what the window means in every other Datus stage —metric_time, D-WINDOW, attribution — and it is what a Tableau author gets by adding the date filter to context.context_filterdoes the same for any other condition: withcontext_filter: "sessions.channel_id = 2"the days are counted on channel 2 only. A context condition cannot read a D-LOD field, since it feeds the rollup that computes it.- The
whereclause is a dimension filter. It selects the rows the view aggregates and never reaches a fixed rollup's input: the query above counts each user's January days across every channel, then keeps the channel-2 rows. A filter on a joined dimension (channel.channel_name = 'pc') is a dimension filter too. A conjunct that names only the rollup's own keys (sessions.user_id <> 'A') is applied to the rollup's output instead — the same rows, through the cheaper two-stage plan; any other stored-column conjunct switches the branch to the broadcast, where it filters the detail rows. - A predicate over a D-LOD field applies to rollup values — between the
two stages, or after the broadcast joins — and never feeds back into any
rollup's input. That is what makes cohorts work:
where: "sessions.first_day BETWEEN …"selects a population by acquisition date while still rolling its members up over all history. It holds across key families too:where: "sessions.daily_users >= 4"on anactive_daydistribution keeps the session rows that fall on busy days, whileactive_dayitself is still counted over every date. - Relationships split the same way. A fixed rollup joins only the
datasets its own expression, keys and context layer read — Tableau's
relationships (the logical layer), where a FIXED value is computed on the
tables it references. A relationship the view joins for a dimension, the
whereclause or another metric reaches the detail rows only. So adding a dimension from another dataset never changes a fixed value, even over an INNER relationship: withsessions → promo_daydeclaredinnerand only one promotion day, grouping bypromo_day.promo_namekeeps the view to that day's sessions whileactive_daystill counts each user's whole history. To compute the rollup over matched rows only, name the far side in the context layer (context_filter: "promo_day.promo_name = 'launch'"): the join then enters the rollup, and with it the rows it drops. - Every conjunct is placed on its own, so an
ANDof a stored-column condition and a D-LOD condition needs no care. A predicate that names both layers in one expression —orders.event_date = orders.first_order_day(first-day revenue), or anOR/NOTacross the two — is evaluated after the broadcast, as a whole.
The where clause therefore has no spelling for the context reading of a
condition ("days active on channel 2 only"); context_filter is that
spelling, per query. A condition that belongs to the dataset itself, as
Tableau's data source filter does, is an inline query source
(source: "SELECT * FROM t WHERE channel_id = 2") today; a dataset-level
filter extension is tracked as issue #197.
Broadcast: reading the value next to the row¶
The two-stage plan reads the rollup and nothing else, which works as long as everything the view selects is a rollup key or a D-LOD field of that rollup. When it is not — a dimension outside the keys, a stored column aggregated per LOD group, a row-level expression that reads the value, two key families in one query — the engine broadcasts instead: the fact is read as detail rows, and each row is joined with its entity's rollup row, one derived table per key family, all computed over the same filtered scan. Many rows meet one rollup row, so the join never fans out. Tableau's FIXED does exactly this.
// average daily actives by channel — channel_id is not a key of the per-date rollup
{"metrics": ["avg_daily_users"], "group_by": ["sessions.channel_id"]}
SELECT g.channel_id, AVG(f.daily_users) AS avg_daily_users
FROM (SELECT channel_id, event_date -- one row per (channel_id, event_date)
FROM main.lod_session GROUP BY channel_id, event_date) AS g
JOIN (SELECT event_date, COUNT(DISTINCT user_id) AS daily_users
FROM main.lod_session GROUP BY event_date) AS f
ON g.event_date IS NOT DISTINCT FROM f.event_date
GROUP BY g.channel_id
A rollup key counts as "a stored column" here when the aggregate counts rows.
The rollup has one row per key, so COUNT(DISTINCT user_id), MIN and MAX
over the key read the same there as on the detail rows and keep the two-stage
plan; COUNT(user_id), COUNT(*), SUM and AVG count the fact's rows and
broadcast — grouped by active_day, two-day users have 8 sessions, not 4.
Two consequences worth knowing:
- Weighting: each key counts once per group. A pinned value is a
property of its key, not of the rows that carry it, so aggregating it
counts each key once per group — the fact is read as one row per (view
dimension × rollup key), the
GROUP BY channel_id, event_datederived table above. Channel 2 appears on three dates carrying 4, 4, 1 → 3.0, not the 3.5 its six session rows would give. Tableau computes the same question the same way. What this is not is a weighted average: if you want the detail rows to weigh, aggregate a stored column instead.
The same holds for a row-level field whose expression reads only D-LOD
values and constants — CASE WHEN active_day > 1 THEN 1 ELSE 0 END is one
value per user however many session rows carry it — so SUM of it counts
each user once per group, exactly as summing active_day would, and
exactly as Tableau evaluates a calculation over FIXED values at the FIXED
grain. A field that also reads a stored column (event_date = first_order_day)
is a property of the row and weighs by rows. The grid is keyed by the view
dimensions and the keys of the families being aggregated; a family the
query only groups by — a D-LOD field, or a field computed from one, as the
view dimension — is joined inside the grid to compute that dimension and
contributes no key of its own.
- One grain per query. A metric that aggregates a pinned value and one
that aggregates a stored column are grained differently — once per key
versus once per row — and a query asking for both is refused by name
rather than answered at one of the two grains. So is a query whose metrics
aggregate values pinned at different key families (AVG(active_day) per
user beside AVG(daily_users) per date): the grid is deduplicated by the
keys being aggregated, and a grid keyed by both would repeat each value once
per key of the other — neither metric would match its answer alone. Query
them separately.
- A NULL key still matches. GROUP BY gives a NULL key its own rollup
row, so the join is null-safe (IS NOT DISTINCT FROM, or the equivalent
OR form where a dialect needs it) — otherwise those detail rows would
leave the aggregate silently, and the same question would read differently
depending on which shape the planner picked. On a dialect that cannot hash
the OR form this is a slower join; declare the column NOT NULL upstream
if that matters.
- A row-level field may read a D-LOD field. CASE WHEN event_date =
first_order_day THEN 'new' ELSE 'returning' END is a legal field expression:
naming a D-LOD field reads the broadcast value, and grouping by the flag
gives new-vs-returning revenue with no new syntax. What such a field cannot
do is feed the value back into a rollup — as a dims key or a base
argument (nested LOD), as a relationship column, or as a time dimension —
all refused at compile time.
Which shape a query gets is decided per branch and is visible in --explain
(LodRollup vs LodBroadcast). The two-stage shape is kept whenever it
suffices, so a query that planned before the broadcast existed plans
byte-for-byte as it did.
The rollup keeps the storage encoding¶
A rollup key is projected as the stored value — no grain truncation, no
D-FORMAT parse. The outer stage owns the view: it parses a
%Y%m%d key and truncates it to the requested grain exactly once
(DATE_TRUNC('MONTH', STRPTIME(f.event_ymd, '%Y%m%d'))). Truncating inside as
well would silently coarsen the rollup itself, changing what the field means.
The expression is not a sentinel¶
The field's expression carries the real aggregate, and the engine checks it
agrees with base (derive_expression_mismatch otherwise). Two reasons: it
is what a consumer that ignores the extension reads, and an aggregate used as
a row-level column fails loudly at the warehouse — strictly better than a
sentinel, which would silently group by a wrong row set. That is also how
basic mode degrades (§5).
View-relative: include and exclude on a metric¶
Where: a Metric's custom_extensions.
Payload key: derive, family {"type": "lod", "expr": …}.
Default (absent): the metric is whatever its expression says.
A fixed field pins one grain for every query, which is why it can be a
field: its value is a property of the row. include and exclude pin a grain
relative to the query's own group-by, so they cannot be — they are hosted on
metrics, where "the value depends on the view" is already the contract.
- name: revenue_per_user
# the nested aggregate verbatim — see "the expression is not a sentinel"
expression:
dialects: [{ dialect: ANSI_SQL, expression: "AVG(SUM(orders.amount))" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.10", "derive": {"type": "lod",
"expr": "avg(include(revenue, orders.user_id))"}}'
The expr is authored as SQL so ordinary tooling can lint it, and reads as a
tiny grammar:
| Form | Inner grouping | Outer aggregate |
|---|---|---|
agg(include(base, dims…)) |
the view's dimensions plus dims — finer |
required: something must collapse the extra rows |
exclude(base, dims…) |
the view's dimensions minus dims — coarser |
forbidden: each view row already reads one inner row |
SELECT f.channel_id, AVG(f.revenue) AS revenue_per_user
FROM (SELECT orders.channel_id, orders.user_id, SUM(orders.amount) AS revenue
FROM main.lod_order GROUP BY orders.channel_id, orders.user_id) AS f
GROUP BY f.channel_id
Which one you want is a real question, and the model answers it. Averaging "revenue per user" by channel has two defensible readings, and they differ whenever a user touched more than one channel:
| Spelling | Reading | By channel |
|---|---|---|
avg(include(revenue, orders.user_id)) |
per user within the channel | pc 12.5, mobile 20.5 |
AVG(orders.user_revenue) over a fixed field |
each user's lifetime total, once per channel they ordered on | pc 17.5, mobile 23 |
Neither is wrong. The engine never picks: each is its own metric, spelled once.
The filter rules are the same as for a field. A query filter and time_range
scope the inner grouping, so the level of detail is computed over the rows the
query asked about. Two differences from a fixed rollup follow from
"view-relative":
- The grain is applied inside. Grouping by
metric_time:monthgroups the inner stage by month and user, then averages — not by day and user. Afixedrollup does the opposite (see above), because there the key is a stored column and the view belongs to the outer stage. - The outer stage reads the rollup and nothing else, so every metric in the query must be a level of detail of the same grain. A plain metric alongside one is refused by name rather than answered at the wrong grain.
exclude attaches, it does not replace. Where include groups finer and
collapses back, exclude groups coarser and carries the result onto every
view row: the fact is aggregated twice and the two are joined on the
dimensions they share (a LEFT JOIN), or on none at all when the declaration
names every view dimension (a CROSS JOIN — the grand total). Each view row
meets exactly one of the coarser rows, so the attach cannot fan out.
- name: revenue_grand # exclude every view dimension: the grand total
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(SUM(orders.amount))" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.10", "derive": {"type": "lod", "expr": "exclude(revenue)"}}'
- name: revenue_share # what it is for: a divisor the view does not group by
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(orders.amount) / SUM(SUM(orders.amount))" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.10", "derive": {"type": "compose", "expr": "revenue / revenue_grand"}}'
WITH m0 AS (SELECT orders.channel_id, SUM(orders.amount) AS revenue
FROM main.lod_order GROUP BY orders.channel_id),
m1 AS (SELECT SUM(orders.amount) AS revenue_grand FROM main.lod_order)
SELECT m0.channel_id, m0.revenue, m0.revenue / m1.revenue_grand AS revenue_share
FROM m0 CROSS JOIN m1
Two things worth knowing:
- A metric composed over a level of detail is one too.
revenue_share's own expression is a nested aggregate, because its divisor's value is not expressible as a flat aggregate — so basic mode fails closed on the share column exactly as it does on its member. The engine finds the derivation through the compose: missing it would not be an error, it would silently compute the divisor at the view's own grain and make every share 1. - The coarser value is recomputed, never re-aggregated. A window function
(
SUM(SUM(x)) OVER (PARTITION BY …)) would get the same answer in one pass for an additive base, and over-count aCOUNT(DISTINCT)one. The side runs the base's own aggregate at the coarser grain, which is right for any aggregate. - With no group-by at all there is nothing to exclude from, so the metric is
simply its base and the plan is one flat aggregation. That is
excludeonly: a keylessfixedmetric still skips the dimension filter with no group-by, so it is still its own side.
fixed on a metric: a value the view does not group by¶
Where: a Metric's custom_extensions.
Payload key: derive, family {"type": "lod", "expr": "fixed(base[, dims…])"}.
The same declaration a field makes, hosted on a metric so that it can
attach to the view instead of being a column of it. With no dimensions it
is Tableau's table-scoped {SUM([Sales])} — one row, CROSS JOIN'd onto every
view row. With dimensions, they must be dimensions the view groups by, and
the side attaches through a null-safe LEFT JOIN: each view row meets exactly
one of its rows, so the attach cannot fan out.
- name: revenue_fixed_all # Tableau {SUM([amount])}
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(SUM(orders.amount))" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.10", "derive": {"type": "lod", "expr": "fixed(revenue)"}}'
- name: revenue_share_of_all
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(orders.amount) / SUM(SUM(orders.amount))" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.10", "derive": {"type": "compose", "expr": "revenue / revenue_fixed_all"}}'
fixed and exclude are not two spellings of one thing. Both can be
keyless, both attach a total to every view row — and under a filter they give
different answers, because fixed is computed before the dimension
filter and exclude is the view's own grouping one
dimension coarser:
{"metrics": ["revenue", "revenue_share_of_all", "revenue_share"],
"group_by": ["orders.channel_id"],
"where": "orders.user_id <> 'A'"}
WITH m0 AS (SELECT orders.channel_id, SUM(orders.amount) AS revenue
FROM main.lod_order AS orders WHERE orders.user_id <> 'A' -- the view
GROUP BY orders.channel_id),
m1 AS (SELECT SUM(orders.amount) AS revenue_fixed_all
FROM main.lod_order AS orders), -- fixed: no WHERE
m2 AS (SELECT SUM(orders.amount) AS revenue_grand
FROM main.lod_order AS orders WHERE orders.user_id <> 'A') -- exclude: scoped
SELECT m0.channel_id, m0.revenue,
m0.revenue / m1.revenue_fixed_all AS revenue_share_of_all,
m0.revenue / m2.revenue_grand AS revenue_share
FROM m0 CROSS JOIN m1 CROSS JOIN m2
Neither denominator is wrong. "Share of everything we sold" and "share of what this filter selected" are different questions, and Tableau has both keywords for exactly that reason. Which one a metric means is written in the model, once.
A keyed fixed reads the same way — the side groups by the declared
dimension and skips the filter, so the filter scopes the numerator only:
{"metrics": ["revenue", "revenue_fixed_by_channel"], // fixed(revenue, orders.channel_id)
"group_by": ["orders.channel_id"], "where": "orders.user_id <> 'A'"}
// channel 1 : revenue 25, fixed 25
// channel 2: revenue 35, fixed 82 — the filter removed 47 from the numerator only
Its keys must be the view's. A fixed metric keyed by something the
query does not group by would meet many view rows at once, and collapsing
them means re-aggregating the fixed values across keys — a grid over (view
dimension × key), which is what a fixed field does when an
ordinary metric aggregates it. The refusal names both ways out. A query with
no group-by is no exception: its view has none of the keys either. For the
same reason an outer aggregate over fixed(…) is refused rather than
computed.
Cross-fact alignment: one axis, two facts¶
Two facts can each compute the same per-entity aggregate — how many days a
user had a session, how many days they ordered — and the business question puts
them on one axis. A conformed pairing whose endpoints are the
two fixed fields says so:
relationships:
- name: sessions_orders_conformed
from: sessions
to: orders
from_columns: [active_day, event_date]
to_columns: [active_day, event_date]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.8", "requires": ["cardinality"], "cardinality": "many_to_many"}'
WITH m0 AS (SELECT f.active_day, COUNT(DISTINCT f.user_id) AS sessions
FROM (SELECT user_id, COUNT(DISTINCT event_date) AS active_day
FROM main.lod_session GROUP BY user_id) AS f
GROUP BY f.active_day),
m1 AS (SELECT f.active_day, COUNT(DISTINCT f.user_id) AS buyers
FROM (SELECT user_id, COUNT(DISTINCT event_date) AS active_day
FROM main.lod_order GROUP BY user_id) AS f
GROUP BY f.active_day)
SELECT COALESCE(m0.active_day, m1.active_day) AS active_day,
COALESCE(m0.sessions, 0) + COALESCE(m1.buyers, 0) AS user_num_total
FROM m0 FULL OUTER JOIN m1 ON m0.active_day = m1.active_day
OR m0.active_day IS NULL AND m1.active_day IS NULL
Each fact rolls up for itself; the branches merge on the shared output name. The two facts are never joined — a session row and an order row have nothing to match on, and joining them would multiply both sides' counts. That is the whole reason the edge is a pairing rather than a relationship.
Everything the two mechanisms already do carries over unchanged: either
spelling of the paired axis may be grouped by, a row-level filter and
time_range scope each fact's own rollup input through its own spelling of
the paired time column, and a bare count fills a missing bucket with 0 while
a SUM stays NULL (S-FAN-6). A single-fact metric may
also be grouped by the other fact's spelling — the branch reads its own,
and broadcasts if its measure aggregates a column the rollup does not export.
What D-LOD does not do yet¶
These are all structured not_implemented refusals naming what would lift
them — or why they never lift — never silently-wrong SQL:
| Query | Why |
|---|---|
agg(fixed(…)) on a metric, or a fixed metric keyed by a dimension the view does not group by |
re-aggregating fixed values across keys is the field carrier's shape — a grid over (view dimension × key); declare the fixed field and aggregate it with an ordinary metric |
| two metrics aggregating values pinned at different key families | one grid is deduplicated by one set of keys; each value would be repeated once per key of the other family. Query them separately |
| a metric homed on another dataset, with no conformed pairing between the two | there is nothing to align the two axes on; declare the pairing |
a plain metric in the same query as an include metric, or a fixed field in the same query as either |
the outer stage reads one grouping; two grains, one of them view-relative, are not nested. (A plain metric beside an exclude is fine — the attach leaves the view's own grouping alone.) |
include and exclude in one query |
they pull the branch's grouping in opposite directions |
exclude with a window metric, under time_ranges, or beside a metric homed on another dataset |
the attach reads one view-grain aggregation |
| grouping by or filtering on a view-relative metric | its grain is defined by the group-by, so it cannot be part of it; the error names the fixed field spelling that can |
a D-LOD field with a window metric, or under time_ranges |
a different stage stack |
an include metric with a window metric, or under time_ranges |
the outer stage reads only the finer rollup; there is no time column left for a window or a per-tag gate |
a where condition on a D-LOD field beside an include, exclude or fixed() metric |
the condition filters rollup values, and the view-relative or attached grain would have to be computed around them — the same nesting as grouping by the field |
a group-by dimension that projects under the same name as a rollup key it is not (acct.user_id beside a rollup keyed by sessions.user_id) |
duplicate_output_name: the grid would take one for the other and join on the wrong values. Rename the field, or group by the key itself |
| attribution of a metric that reads a level of detail, or across a D-LOD dimension | the analysis compares two windows in one statement, and a rollup is a function of each window's filter context — it would have to be computed once per window. The metric is refused up front as unsupported / level_of_detail (and is attributable: false in dosi list metrics); the dimension is skipped with a dimension_skipped warning before any statement runs |
dosi select projecting or filtering on a D-LOD field, or on a field that reads one |
the detail plane has no rollup stage; the error names the dosi query spelling |
A keyless fixed field is not among these: lod with no dimensions is
the table-scoped level of detail, broadcast onto every row so a row-level
expression can read it.
Also fixed by construction: include / exclude are view-relative, so they
are hosted on metrics rather than fields (above) —
declaring one on a field names where it belongs. A D-LOD field must not be
dimension.is_time: true, and neither may a field that reads one: a rollup value is not an axis a time range or grain
can be pushed to. A temporal datatype alone does not make either one an axis
— a first-order date typed Date is fine, it is just never a time dimension.
Nested LOD — a dims entry or a base argument that is, or
reads, a D-LOD field, or a base (of a field or of a derive) that is itself
a level-of-detail metric, whatever order the document declares them in — is
refused, as is a D-LOD read in a relationship column. A window.base naming a
level-of-detail metric is refused too (not_implemented): a window over a
level of detail is not supported.
4. Precedence & defaults summary¶
| Point | Ossie core | Datus extension | Engine default when absent |
|---|---|---|---|
| relationship join type | undefined (LEFT assumed) | D-JOIN join_type |
left |
| fact-to-fact conformed pairing | inexpressible (every relationship is a join) | D-CONFORM cardinality |
many_to_one — the edge is a join |
| missing-group metric value | undefined | D-FILL fill_nulls_with |
count→0 (S-FAN-6), else NULL |
| primary time dimension | undefined (is_time only) |
D-TIME time_dimension (metric > dataset) |
the dataset's single is_time field, else none |
| native time grain | no explicit grain; Date implies day |
D-GRAIN time_granularity |
inferred from type/encoding, otherwise unknown |
| time column storage format | logical datatype; no encoding layout |
D-FORMAT time |
assumed native DATE/TIMESTAMP |
COUNT(*) home dataset |
undefined in multi-dataset models | D-DATASET dataset |
SQL-derived, else count_star_needs_dataset |
| window derivation | inexpressible (window SQL rejected) | D-WINDOW window |
plain base aggregate |
| query-time parameters | inexpressible | D-PARAM params |
none — every slot is the literal it declares |
| which fields are offered as dimensions | every non-time field is a dimension | D-DIM is_dimension |
inferred from keys, relationships and aggregated columns |
| aggregate-as-dimension | inexpressible (a field is row-level) | D-LOD lod |
none — a field is a stored column |
5. Validation & engine modes¶
- Ossie gate (
scripts/validate_osi.py): unaffected —custom_extensionsis valid Ossie, so all fixtures still PASS ossie. - Datus self-validation (engine in
--osi-datusmode, the default, when it reads aDATUSentry): the payload must be a JSON object; known keys must match their declared type/enum; otherwiseinvalid_datus_extension. Unknown keys pass (forward-compat) unless named inrequires. The envelope version gate (§2) runs here, once per payload — including on theSemanticModelcarrier, which defines no keys of its own but is still held to the envelope. - Basic mode (
--osi-basic): the engine treatsDATUSlike any unknown vendor — the extension is inert (spec-compliant passthrough) and the compile collects oneignored_vendor_extensionwarning per carrying element — SemanticModel, Dataset, Field, Relationship, and Metric alike — naming the default that applies instead (D-JOIN → LEFT, D-FILL → no fill beyond S-FAN-6, D-TIME → single-is_timeinference only, D-GRAIN → any grain, D-DATASET → SQL-derived attribution only, D-WINDOW → the plain base aggregate, D-FORMAT → the column is assumed to be a native date, which for a string or integer column means a warehouse type error or silently wrong rows, D-LOD → the field is dropped, D-CONFORM → the edge is an ordinary many→one join, so the two facts are joined row to row and duplicate-sensitive aggregates across it fan out). D-LOD is the one that cannot degrade to a value: without the declaration the field's expression is a bare aggregate, which is not a legal row-level attribute, so the field is dropped with anaggregate_field_droppedwarning and any metric or query referencing it fails closed withunknown_field/unknown_column. The alternative would beAVG(COUNT(DISTINCT …))reaching the warehouse. The payload is not parsed, so a malformed payload is also just ignored-with-warning — and no version diagnostic can occur in basic mode at all. The same model file is valid in both modes; only the semantics differ. (The single-is_timeprimary-time inference is not an extension — it applies in basic mode too, sometric_timestill works wherever pure Ossie determines a unique time axis.) - Implementation boundary: all extension reading — and the mode branch
itself — lives in one module,
crates/dosi-compiler/src/ext.rs. Future extensions land there with the same shape; the rest of the engine stays extension-agnostic.
6. Non-goals / boundaries¶
- Datus extensions never redefine an Ossie object's identity or a metric's SQL — only engine behavior at Ossie-undefined seams.
- They are not a place to smuggle vendor SQL. Metric expressions stay Ossie
expression.dialects; the engine still infers metric semantics from that SQL. - Semantics that belong in Ossie core (granularity, time spine, window/cumulative metrics) may eventually be standardized upstream; if/when adopted there, the corresponding Datus extension (D-GRAIN, D-WINDOW) is retired in favor of the core field.
7. Change log¶
Each released minor and the keys it added. The machine-readable form of this
table is capabilities().history — dosi info --format json or
GET /v1/capabilities.
| Version | Date | Keys added | Impact if ignored |
|---|---|---|---|
| 1.0 | 2026-07-11 | join_type (D-JOIN), fill_nulls_with (D-FILL) |
documented, documented |
| 1.1 | 2026-08-02 | time_dimension (D-TIME), time_granularity (D-GRAIN), dataset (D-DATASET) |
degraded, documented, degraded |
| 1.3 | 2026-08-09 | (none — in-family growth of window) |
(inherits window's documented) |
| 1.4 | 2026-08-12 | derive (D-DERIVE M1), measure (D-MEASURE) |
degraded, silent |
| 1.5 | 2026-08-26 | params (D-PARAM M1) |
degraded |
| 1.6 | 2026-09-02 | time (D-FORMAT) |
silent |
| 1.7 | 2026-09-11 | is_dimension (D-DIM) |
documented |
| 1.8 | 2026-09-16 | cardinality (D-CONFORM) |
silent |
| 1.9 | 2026-09-16 | (none — in-family growth of window) |
(inherits window's documented) |
| 1.10 | 2026-09-23 | lod (D-LOD; also the lod family of derive) |
degraded |
- 1.0 (2026-07-11): initial —
D-JOIN(relationshipjoin_type, implemented: dosi-compiler reads it intoRelationshipIr.join_kind, planner emits INNER/LEFT accordingly),D-FILL(metricfill_nulls_with, implemented 2026-07-12:MetricIr.fill_nulls_with→ planner COALESCEs the metric output; overrides the S-FAN-6 count default, any metric kind). - 1.1 (2026-08-02): time & attribution —
D-TIME(dataset/metrictime_dimension, implemented:DatasetIr.primary_time_dimension+MetricIr.time_dimension, the reservedmetric_timequery name, per-branch time-range fallback),D-GRAIN(fieldtime_granularity, implemented:FieldIr.time_granularity, finer-grain requests →grain_too_fine),D-DATASET(metricdataset, implemented:COUNT(*)home attribution). Basic-modeignored_vendor_extensionwarnings now cover all five carrier objects (previously only Relationship and Metric). - 1.1 (2026-08-04, no version bump): the envelope version gate itself —
vis now read and enforced (§2),requiresis introduced, each key carries a declared ignore-impact, and the whole registry is published throughdosi info,GET /v1/capabilities, anddosi_engine.DATUS_EXT. No key was added or changed, so the version stays at 1.1. Payloads without av— which is what the Datus agent emits today — are unaffected. - 1.2 (2026-08-06): window metrics —
D-WINDOW(metricwindow, implemented:MetricIr.window→ the planner'sWindowStageprojection root; pop/rolling/cumulative sugars + general offset/frame form, cumulative reset with scan expansion + post-window trim; ignore-impactdocumented— the metric falls back to its plain base aggregate; spec inwindow-extension.md). Promotes the 13 baisheng derived-time cases Deferred→Supported (55/2). Initially DuckDB-executed; the all-dialect enablement (per-dialect offset-shift lowerings, all-dialect snapshots, live-corpus verification,datus_windowcorpus scenario) landed as a follow-up. In-family additions (same minor, fail-closed on engines predating them): mixed families / mixed shifts / mixed resets-with-start relaxed, andrequire_full_windowon finite frames (2026-08-06) — seewindow-extension.md#restrictions. - 1.3 (2026-08-09): window families & functions — grows the
windowkey in-family, no new registry row (capabilities().historystill ends at 1.2; the version signals the payload dialect). Additions: therankfamily (row_number / rank / dense_rank / ntile / percent_rank / cume_dist over the metric value), thevaluefamily (first_value / last_value / nth_value), offsetdirection: "forward"(LEAD — next-period reference), frameorder/partition/units/secondmodifiers (value-ordered and RANGE frames, partition modes, two-argument inputs), and registry levels W2 (stddev_pop/samp,var_pop/samp) + W3 (covar_pop/samp,corr). Every addition fails closed on a 1.2 engine: unknown top-level family keys fall into the exactly-one-family error, unknown in-family keys hitdeny_unknown_fields, unknown function names fail enum parse — and a declared"v": "1.3"trips the version gate besides. Spec:window-extension.md(family sections + function registry). - 1.4 (2026-08-12): derived metrics & measure naming —
D-DERIVEM1 (derivekey, implemented:filter+composefamilies, theresolve_metric_refspass with cycle detection, the expression equivalence gate, compile-time conformed dimensions, and derive metadata on every host surface;reagg/ cross-datasetfilterpredicates /window.baseare reserved and rejectnot_implementednaming their milestone — notewindow.basepreviously fell into the silently-ignored unknown-key bucket and now fails closed) andD-MEASURE(measurekey, implemented: explicit measure naming, signature-keyed dedup and collision checks unchanged — the fix for the non-ASCII stem collision, and the firstsilent-impact key: emit it underrequires). M2 (2026-08-19, in-family — no new keys, no version bump):window.baseflips from reserved to functional (windows over leaf / filter metrics), compose accepts window members (stage-tier lowering: arithmetic over window output) and filter members (flatten tier). A 1.4 payload using these shapes compiles on an M2 engine and fails closed (not_implemented/ rejection) on the M1 engine — never a silent misread. Nested compose (in-family — no new keys, no version bump): a compose member may itself be a compose, expanded inline at compile time. An older engine rejects the shape with the sameinvalid_datus_extensionit always did, so this too fails closed. The metric stays on the flatten tier, andderive_membersbecomes the transitive leaf list with folded coefficients; a window anywhere in the tree keeps the combinationnot_implemented. - 1.6 (2026-09-02): time storage formats —
D-FORMAT(fieldtimeblock, implemented:FieldIr.time_format,SqlBackend::parse_timeon the grouping side and a Rust-side literal fold on the filter side, with the grain inferred from the layout).time.granularityjoins the flattime_granularityas an equal spelling — no deprecation, but the two may not disagree. The secondsilent-impact key: emit it underrequires, so a datus-mode engine that lacks it fails at the version gate (basic mode never readsrequires; see §2). A consumer that drops it does not fail loudly; it assumes a native date column and, for a month- or year-stored one, returns an empty result with no diagnostic. See D-FORMAT for configuration and limitations. Scope: string and integer layouts plus the native temporal types;epoch_seconds/epoch_millisare deferred becausefrom_unixtimereads the session timezone and that needs its own round.
In-family addition (2026-09-08, still 1.6): time.format accepts continuous ordered fixed-width
calendar prefixes with arbitrary fixed literal text. Filtering still folds
date bounds to stored constants; grouping extracts components into a canonical
date/time before dialect parsing. Existing layouts remain compatible. Older
Datus engines reject new layouts rather than silently interpret them.
- 1.7 (2026-09-11): field roles — D-DIM (field is_dimension,
implemented: FieldIr.is_dimension, read by list_dimensions and by
attribution's candidate list). The model can finally say that a column is a
measurement, an identifier, free text or PII, so it stops being offered as a
grouping dimension. It is a recommendation signal only: an explicitly named
group_by is honored either way and no compiled SQL changes, hence
documented impact and no requires entry. Where the key is absent the
engine infers the role from model structure alone — keys, relationship
columns and numerically aggregated columns — and never probes the warehouse.
See D-DIM.
- 1.8 (2026-09-16): conformed fact-to-fact relationships — D-CONFORM
(cardinality key on a Relationship, implemented:
RelationshipIr.kind, the join graph's conformed classes, per-branch
resolution of group-by / filter / time-range columns and of metric_time
to each fact's own spelling, merge on the output name). A many_to_many
edge is never joined; only its paired columns are shared. The third
silent-impact key: emit it under requires — without it the edge is a
many→one join and duplicate-sensitive aggregates fan out. Design:
../design/d-conformed-edges.md; fixture: fixtures/datus_conform/.
- 1.9 (2026-09-16): a ranked scorecard — grows the window key in-family
the way 1.3 did, so there is no new registry row. Four additions driven by
one shape.
frame.scope: "partition" states the frame as the entire partition instead
of an offset from the current row, lowering to <func>(…) OVER (PARTITION BY
…) with no ORDER BY and no frame clause. It is the second window function
beside a rank — "how many rows am I ranked against" — which a ranked
scorecard needs and which no combination of the existing keys could express:
count with preceding: "unbounded" is a running count, and units:
"range" still stops at the current row's peer group. scope is mutually
exclusive with preceding, reset, require_full_window, units and
order, each of which only describes an ordered frame; writing one is
rejected rather than ignored.
window.base widens: a ratio or a compose can now be the series a window
reads, so ranking a rate or taking its period-over-period is expressible. A
cross-fact compose's series only becomes a value after the branch merge, so
the window stage sits on the merge — the branches aggregate separately and
their CTEs ride beside the stage's base. Such a base has no shared time axis
(each fact carries its own), so the families that need one refuse on
conformance.
Fails closed on a 1.8 engine twice over: FrameSpec and OrderSpec are
deny_unknown_fields, and the payload's "v": "1.9" trips the version gate.
See window-extension.md.
Nested windows and NULL placement complete the shape. order.nulls:
"first" | "last" pins where NULLs sort in a window ordering — left unset,
each warehouse applies its own default and they disagree (DuckDB and
PostgreSQL last ascending, MySQL-family and StarRocks first), which a ratio
base turns from theoretical into routine. The generator emits the clause
only where it differs from the target's default, and where a dialect has no
such clause at all (MySQL, TiDB) the placement lowers to a leading
CASE WHEN … IS NULL sort key — so the rendering varies by dialect and the
meaning does not. And window.base goes one step further than a ratio: it
accepts a compose over another window's output, so a second window can read
the first one's. Each
level lowers to one more SQL stage; depth is capped at 3 and level 2+ must
declare partition. The guards that matter run across levels, not within
one: the output trim stays outside the outermost stage, and a
partition-global metric at any level refuses to share a query with a
scan-expanding one at any other. Also in-family: a stage-tier compose may
have a compose member (it stays a member rather than being inlined, since
on that tier each member is its own series).
- 1.10 (2026-09-23): levels of detail — D-LOD, one grammar on two
carriers: the lod key on a Field and the lod family of the existing
derive key on a Metric (implemented).
On a field, fixed(base, dims…) makes a per-entity aggregate groupable,
filterable and re-aggregatable through an ordinary query (FieldIr.lod →
the planner's LodRollup stage; a D-FORMAT rollup key keeps its storage
encoding and is parsed once, outside). When the view needs more than the
rollup — a dimension outside its keys, a row-level field reading the value,
several key families — the broadcast shape (LodBroadcast) joins the detail
rows with one derived table per key family; an aggregate over a pinned
value counts each of its keys once per group. No keys is the table-scoped
FIXED, broadcast onto every row.
On a metric, include groups the inner stage by the query's own group-by
plus the declared dimensions and collapses it with the declared outer
aggregate; the grain and any D-FORMAT parse apply inside. exclude
attaches a coarser aggregation of the same fact to the view's rows — LEFT
JOIN on the dimensions they share, CROSS JOIN on none — recomputed rather
than windowed so a COUNT(DISTINCT) base is right too. fixed is the
field's declaration hosted on a metric so it can attach to the view: a
table-scoped or key-scoped total computed before the dimension filter,
which is what Tableau's {SUM([Sales])} is as a denominator and what
exclude() cannot mean (above). A conformed pairing
whose endpoints are two facts' own fixed fields aligns them on one axis:
each fact rolls up for itself and the branches merge, the facts are never
joined.
Filter placement follows Tableau's order of operations. The where clause
is a dimension filter — applied to a fixed rollup's output when it names
only the rollup's keys, to the detail rows otherwise, never inside the
rollup — while time_range is the context filter that scopes every rollup.
A row-level field that reads only D-LOD values aggregates once per key.
Basic mode drops a D-LOD field (aggregate_field_dropped) and fails closed
on a D-LOD metric, whose expression is the nested aggregate its
declaration describes (nested_aggregate), rather than computing one level
of it. Design, cases and the Tableau comparison: ../design/d-lod.md; fixtures:
fixtures/lod/, fixtures/tableau_official_*/.
Not yet implemented (roadmap)¶
The queued RFCs are all additive, so each lands as one minor bump with its own registry rows. Order may change; every entry occupies a minor of its own.
| Minor | RFC | Keys | Carrier | Impact | requires? |
|---|---|---|---|---|---|
| ≥1.11 | rfc-semi-additive-metrics §4 |
semi_additive |
Metric | silent — an end-of-period balance gets summed across snapshots |
yes |
| ≥1.11 | rfc-minimal-declarations §3 D-DISTINCT-STATE |
distinct_state |
Field / Dataset | silent — a materialized bitmap column is aggregated as if it were an ordinary column |
yes |
| ≥1.7 | rfc-derived-metrics M3-M4 |
derive reagg + cross-dataset filter (window.base and compose window/filter members shipped in-family at 1.4/M2) |
Metric | degraded/error — reserved shapes reject not_implemented today, never silently compute |
no |
| — | window-extension.md rank/share/streak families, W2+ functions |
future window shapes (in-family additions, fail-closed on older engines) |
Metric | degraded |
no |
Three silent keys have shipped: measure (D-MEASURE) at 1.4, time
(D-FORMAT) at 1.6 and cardinality (D-CONFORM) at 1.8. If you author
models against a Datus agent that emits
silent-impact keys, keep an eye on the requires list it produces: that is
what stops an older engine from quietly returning wrong numbers, and it only
works because requires itself shipped at 1.1 — ahead of the first key that
needs it.