Skip to content

Saiku semantic annotations

Mondrian’s generic <Annotation> block is a free-form key/value bag attached to any schema element (Cube, Dimension, Hierarchy, Level, Measure, …). Saiku reserves the saiku.semantic.* namespace inside that bag for a small typed vocabulary that drives:

  1. The AI Ask layer — descriptions and synonyms give the LLM real business context; aggregation kinds, grains, and required filters keep its query suggestions sane.
  2. PII protection — the saiku.semantic.pii=true marker flips a level or measure into “structurally present but opaque” mode in agent-facing surfaces and blocks drillthrough, share/embed exposure, and download paths from leaking the underlying values.

All keys are optional. A schema without any saiku.semantic.* annotations runs identically to one without the annotation block; the namespace is purely additive.

At a glance

Annotation keyValid onType / valuesPurpose
saiku.semantic.descriptionDimension · Measure · Levelfree textBusiness description; surfaced in /ai/schema so the LLM has context
saiku.semantic.synonymsDimension · Measure · LevelCSV listAlternative names so the AI can resolve “country” → “Store Country”
saiku.semantic.unitMeasurefree text (e.g. USD, kWh, count)Unit string; flows into the AI cell {value, formatted, unit} shape
saiku.semantic.currencyMeasureISO 4217 code (e.g. USD, EUR)Currency hint for monetary measures
saiku.semantic.aggregation_kindMeasureenum: sum · count · distinct-count · non-additiveAggregation semantics. Drives sort/total semantics in the AI layer.
saiku.semantic.cardinalityLevelenum: low · medium · highMember-count hint. Drives picker UX and AI cost hints.
saiku.semantic.grainLevelenum: year · quarter · month · week · day · hour · minuteTime grain for date-filter modal and time-series chart inference
saiku.semantic.required_filtersLevelCSV of (hierarchy, level) pairsLevels the AI must include in filters whenever this level is touched
saiku.semantic.piiLevel · Measureboolean — true yes y on 1 / false no n off 0PII marker — see PII protection below

Unknown values for the enum-typed fields are logged at WARN with the allowed list and otherwise ignored — a typo doesn’t break the schema.

The same applies to saiku.semantic.pii: an unrecognised value means not PII, and is logged at WARN naming the value and the accepted spellings. The trade-off is deliberate — for an analytics product, masking working data because of a slip is a worse everyday failure than a schema author correcting a value. It does mean a typo leaves the column unprotected, so treat that WARN as actionable.

Where to put them

Annotations attach to any schema element via a child <Annotations> block (XML) or an annotations: key (YAML).

<Measure name="Quantity" column="quantity_units" aggregator="sum" formatString="#,##0">
<Annotations>
<Annotation name="saiku.semantic.description">Total units prescribed (across all dispensings).</Annotation>
<Annotation name="saiku.semantic.synonyms">units, quantity, scripts</Annotation>
<Annotation name="saiku.semantic.unit">count</Annotation>
<Annotation name="saiku.semantic.aggregation_kind">sum</Annotation>
</Annotations>
</Measure>

Levels accept the same <Annotations> shape:

<Level attribute="Year">
<Annotations>
<Annotation name="saiku.semantic.description">Calendar year.</Annotation>
<Annotation name="saiku.semantic.synonyms">annual, yearly, fiscal year, y</Annotation>
<Annotation name="saiku.semantic.cardinality">low</Annotation>
<Annotation name="saiku.semantic.grain">year</Annotation>
</Annotations>
</Level>

In YAML, a level with nothing to annotate is just the attribute name as a bare string (- "Year"); the map form is what you reach for once it needs a key of its own. See YAML schemas for the whole format.

PII protection

Marking a level or measure with saiku.semantic.pii=true opts it into Saiku’s PII enforcement stack. The annotation is the declaration — Saiku layers four enforcement points on top of it.

1. AI schema projection — descriptive metadata stripped

The /ai/schema/{connection/catalog/schema/cube} endpoint feeds the LLM the cube’s structure. For any PII-flagged element it strips:

StrippedKept
displayNamename
descriptionuniqueName
synonymspii (so downstream layers still see the flag)
unit, currency (measures)cardinality, grain, requiredFilters (levels)
sample members — replaced with a single [REDACTED] sentinel, so the shape “this level has members” survives without leaking captions

Aliases are stripped too: any display-name alias resolving to a PII-flagged measure or level is dropped from the alias maps, so a lookup can’t reach a redacted slot under another name.

The level or measure still appears structurally — the agent knows it exists and can reference it by name — but the descriptive metadata and values stay opaque. That is deliberate: an agent that cannot see the column at all tends to invent one.

2. Drillthrough returns= blocked

Drillthrough returns raw fact rows, so this is the last gate between an agent’s projection list and unaggregated personal data.

Drillthrough requests carry a returns= parameter listing the columns to export. If any token resolves to a PII-flagged level or measure, the resolver throws AiPiiException and the endpoint returns 403 with the offending column named. The agent can retry with a narrower projection.

The gate is on the column, not the spelling. Both of these are refused for the same flagged column:

returns=Salary ← bare caption
returns=[Measures].[Salary] ← fully-qualified MDX
HTTP 403
{
"field": "returns",
"error": "Column '[Measures].[Salary]' is annotated PII (saiku.semantic.pii=true) and cannot be
projected through DRILLTHROUGH ... RETURN. Pick a non-PII column from the candidate list.",
"available": ["Unit Sales", "Store Cost", "City"]
}

The available list deliberately excludes every PII-flagged column, so an agent cannot mine the self-correction hint to enumerate what is protected.

A bracketed identifier the schema does not recognise is still passed through untouched — Mondrian remains the judge of MDX it alone understands. The gate adds a refusal, never a new rejection.

3. Embed/share tokens auto-redact

When you mint an embed or share token bound to a query that touches a PII-flagged level, the inspector escalates the token’s redaction policy to FORCE_ON regardless of the operator’s default. Recipients of the token see redacted cells even if the embed wasn’t explicitly configured for redaction. Eliminates the “I forgot to turn on redaction” footgun on shared links.

4. AI data policy gating

PII annotations are cube-level facts. They pair with the SAIKU_AI_POLICY environment variable, which is the deployment-level dial:

SAIKU_AI_POLICYWhat may cross the trust boundary
schema-onlySchema metadata and sample members only. No cell values at all.
aggregatedAdds aggregated result values (the typed {value, formatted, unit} cells).
fullAdds raw fact rows from drillthrough. PII annotations still apply.

Set via the SAIKU_AI_POLICY environment variable or the ai.policy system property — the environment wins. Values are case-insensitive and accept either separator, so schema-only, SCHEMA_ONLY and Schema_Only are equivalent.

The tiers are a ladder: each permits everything below it. The annotation says “this column is sensitive”; the policy says “this is how much data of any kind we let out”. Both must allow a thing for it to leave.

The default is schema-only, so an unconfigured deployment leaks no values at all — you opt in to looser tiers, never out of them.

There is a second, independent dial for the same question one layer out: SAIKU_AI_LLM_EGRESS / ai.llm.egress uses the same three tiers and answers “may cell data reach a third-party LLM vendor?” separately from “may it return to the authenticated caller?”. It also defaults to schema-only.

Pre-flight scanner

PiiScanner runs over a cube’s schema at build time and logs WARN lines for any measure or level whose name looks PII-shaped but carries no annotation. The warning includes a paste-ready XML snippet so the schema author can opt the column in without hunting through the spec:

WARN [PiiScanner] Likely PII column 'Customer Email' on level [Customer].[Customer].[Email]
is not annotated. Add:
<Annotations>
<Annotation name="saiku.semantic.pii">true</Annotation>
</Annotations>

The scanner never mutates the schema and never auto-applies the flag — it surfaces candidates, and the schema author decides. A column you have already annotated is never re-suggested, so the warnings stay signal.

What it actually matches

RuleMatches
emailemail, e_mail, mail
ssnssn, social_security, nin, sin
phonephone, mobile, tel, tel_no, tel_number
namename, first_name, last_name, surname, given_name, full_name, customer_name
dobdob, date_of_birth, birth_date, birthdate
addressaddress, street, postcode, postal_code, zip, zip_code
medical_recordnhs, medical_record, mrn
financialaccount_number, account_no, iban, swift, bic, ccn, credit_card

Matching is case-insensitive and word-boundary anchored. That boundary is deliberate — it is what stops email_count (a COUNT(emails) aggregate, not personal data) from firing on the email rule.

Worked example — Pharma demo cube

The Pharma demo schema ships with PII annotations on prescriber name + NPI, and leaves specialty + decile un-annotated (those are operationally safe to share). Excerpt from saiku-home/data/Pharma.xml:

<Dimension name="Prescriber" table="dim_prescriber" key="Prescriber">
<Attributes>
<Attribute name="Specialty" keyColumn="specialty" hasHierarchy="false"/>
<Attribute name="Decile" keyColumn="decile" hasHierarchy="false"/>
<Attribute name="Prescriber" keyColumn="prescriberkey"
nameColumn="prescribername" hasHierarchy="false"/>
<Attribute name="NPI" keyColumn="prescribernpi" hasHierarchy="false"/>
</Attributes>
<Hierarchies>
<Hierarchy name="Prescriber" allMemberName="All Prescribers">
<Level attribute="Specialty"/>
<Level attribute="Decile"/>
<Level attribute="Prescriber">
<Annotations>
<Annotation name="saiku.semantic.pii">true</Annotation>
</Annotations>
</Level>
</Hierarchy>
<Hierarchy name="NPI" allMemberName="All NPIs">
<Level attribute="NPI">
<Annotations>
<Annotation name="saiku.semantic.pii">true</Annotation>
</Annotations>
</Level>
</Hierarchy>
</Hierarchies>
</Dimension>

Note where the annotation goes: on the <Level>, not the <Attribute> that supplies its column. The level is what a query names, so that’s what the policy layer checks.

With this in place, an AI agent asking “break down Quantity by Specialty” succeeds normally, but “break down Quantity by Prescriber and download the rows” either refuses (drillthrough block) or returns aggregated buckets with prescriber names suppressed (depending on the AI policy tier).

Note that neither of these columns would have been suggested by the pre-flight scanner: prescribername is run-together so it misses the word-boundary-anchored name rule, and there is no NPI rule at all. They are annotated because someone decided they were sensitive — which is the intended workflow. The scanner catches common shapes; domain identifiers are yours to know.

Worked example — FoodMart cube

FoodMart ships with the full descriptive annotation set (no PII columns — synthetic data). Lifted from saiku-home/data/FoodMart4.xml:

<Level attribute="Store Country">
<Annotations>
<Annotation name="saiku.semantic.description">Store's country.</Annotation>
<Annotation name="saiku.semantic.synonyms">nation, country code</Annotation>
<Annotation name="saiku.semantic.cardinality">low</Annotation>
</Annotations>
</Level>
<Level attribute="Year">
<Annotations>
<Annotation name="AnalyzerDateFormat">[yyyy]</Annotation>
<Annotation name="saiku.semantic.description">Calendar year.</Annotation>
<Annotation name="saiku.semantic.synonyms">annual, yearly, fiscal year, y</Annotation>
<Annotation name="saiku.semantic.cardinality">low</Annotation>
<Annotation name="saiku.semantic.grain">year</Annotation>
</Annotations>
</Level>

The same pattern scales across every dim + level. Each line is independently optional, so you can roll the annotations out incrementally.

Validation and unknown values

  • Enum-typed fields (aggregation_kind, cardinality, grain) validate against their allowed list. Unknown values log a WARN with the offending value + the allowed list and the field defaults to unset.
  • Boolean fields (pii) accept true / false case-insensitively after trim. Anything else is false. Schema authors get the conservative default if they mistype.
  • Free-text fields (description, unit, currency) are trimmed; empty strings become null.
  • synonyms and required_filters parse as CSV; empty entries are dropped.
  • Exporting to Apache Ossie — how the saiku.semantic.* annotations survive round-trip into portable Ossie YAML (description + synonyms lift into ai_context; everything else rides in custom_extensions as SAIKU vendor data).
  • Well-known Ossie extensions — the Ossie-side equivalent (saiku.display, saiku.roles, saiku.pii) for YAML models. Same intent, different file format.
  • Extensions: functions and formatters — the generic <Annotation> block on which saiku.semantic.* builds (and its locale-metadata convention).
  • YAML schemas — full YAML reference, including annotations: on every element.
  • Access control and roles — schema-level access enforcement (complements PII annotations: ACLs gate who can query a cube; PII annotations gate what the result exposes once they can).