Skip to content

DataHub, Polaris and Trino

Many AI agents use an LLM to translate a user’s question directly into SQL. The SQL may run successfully and still return the wrong answer. The LLM can choose the wrong column, create an incorrect join, or use the wrong aggregation without producing an obvious error.

With Semantic Operator, an AI agent selects certified metrics and dimensions instead of generating SQL. Semantic Operator validates the request, applies governance policies, and deterministically generates a single SQL statement.

This walkthrough shows how our Semantic Operator provides a governed semantic layer using DataHub, Polaris, and Trino.

Why direct SQL can return the wrong answer

Section titled “Why direct SQL can return the wrong answer”

Consider a simple business question: How much sales revenue is generated per employee in each state?

In the semantic model, this business concept is named store_productivity. It divides the total sales revenue from stores in a state by the total employee headcount for those stores.

An LLM can generate SQL that looks correct but counts a store’s employees once for every sale made there. That repeated headcount inflates the denominator and produces a plausible but incorrect result. The database reports no error because the query itself is valid.

Later in this walkthrough, you will query store_productivity by state. The governed request returns 644,567.24 for Tennessee and 1,826,350.38 for Texas. You will inspect the generated SQL and see how Semantic Operator avoids duplicating employee headcount across the sales join.

A semantic layer gives business concepts clear, reviewed definitions. It defines which columns represent those concepts, how metrics are calculated, how tables relate to one another, and who is allowed to access the data.

In this example, total_sales uses the column the business recognizes as revenue. store_productivity calculates sales revenue per employee without counting the same employees once for every sale.

An AI agent selects these certified metrics and dimensions instead of using an LLM to generate SQL. Semantic Operator validates the request, applies the model’s governance rules, and generates one deterministic SQL statement.

The same model, request, and caller identity produce the same SQL and result. If the caller requests a metric or field they are not allowed to use, the Semantic Server rejects the request before generating any SQL.

Semantic Operator runs this governed semantic layer on Kubernetes.

This walkthrough takes you through six stages on a live Kubernetes cluster

  • Load a TPC-DS retail dataset into Apache Iceberg tables stored in Amazon S3 and cataloged by Apache Polaris.
  • Install Semantic Operator and connect it to Trino.
  • Add business descriptions, glossary terms, and a PII classification in DataHub.
  • Derive a semantic model from the physical schema, enrich it with DataHub metadata, and compare it with the certified model.
  • Deploy the certified model and watch Semantic Operator validate its tables and columns against Trino.
  • Query the model, compare the governed result with the LLM-generated result, and verify column and row-level access policies.

This walkthrough combines an open table format, an open catalog, a distributed SQL engine, a metadata platform, and Semantic Operator. Each component has a specific role.

Component What it does here
Amazon S3 Stores the Iceberg data files
Apache Iceberg Provides the table format for the retail data
Apache Polaris Catalogs the Iceberg tables and tracks their locations
Trino Generates the sample data, reads the tables, and executes SQL
DataHub Stores business descriptions, glossary terms, and data classifications
Semantic Operator Validates semantic models, enforces governance, and generates SQL
Apache Ossie Provides the open standard used to define the semantic model
Valkey Optionally caches query plans and results

The walkthrough uses the TPC-DS retail dataset generated by Trino, so you do not need to provide any source data.

User
|
| "How much sales revenue is generated per employee in each state?"
v
AI agent
|
| Selects the certified metric and dimension
| metric: store_productivity
| dimension: store.s_state
v
Semantic Server
|
| Validates the request and checks the caller's permissions
| If access is denied, the request stops here
|
| Generates one governed SQL statement
v
Trino
|
| Resolves the Iceberg tables through Apache Polaris
| Reads the underlying data from Amazon S3
v
Query result

DataHub is part of the authoring path, not the query path. While the model is being created, ossiectl reads the physical schema through Trino and enriches it with descriptions, glossary terms, and classifications from DataHub. A person then reviews and completes the model before deploying it.

After the model is deployed, Semantic Operator checks its tables and columns against Trino before publishing it. At query time, the AI agent selects certified metrics and dimensions. Semantic Operator applies governance and generates the SQL that Trino executes.

Install kubectl, helm, aws, jq, git, and Go 1.26 on your workstation.

Deploy the semantic-on-eks stack from Data on EKS:

Terminal window
./deploy.sh

It deploys EKS, Trino, Polaris, DataHub, Valkey, an S3 bucket, and the required IAM roles. See data-stack.tfvars for the configuration.

You can also use an existing cluster with Trino, Polaris, and DataHub.

Terminal window
kubectl get nodes | head -3
kubectl -n trino get pods
kubectl get pods -A | grep -E 'polaris|datahub-gms'
aws sts get-caller-identity
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- \
trino --execute "SHOW CATALOGS" 2>/dev/null | grep polaris

The final command must print polaris. The terminal warning hidden by 2>/dev/null is harmless.


A semantic model describes tables, so you need tables first. This builds five retail tables in Polaris using CTAS from Trino’s built in tpcds connector. Trino writes the Iceberg files and Polaris tracks the metadata. The data is generated inside the coordinator, so this needs no pre existing warehouse.

Terminal window
bash examples/stacks/eks/datahub-polaris-trino/data-load.sh

Verify: the script prints its own checks at the end.

Expected load output, first run
[data-load] creating schema polaris.osi_demo
CREATE SCHEMA
[data-load] loading date_dim (2000-2002)
CREATE TABLE: 1096 rows
[data-load] loading item
CREATE TABLE: 18000 rows
[data-load] loading customer
CREATE TABLE: 100000 rows
[data-load] loading store
CREATE TABLE: 12 rows
[data-load] loading store_sales (this is the big one, a few minutes)
CREATE TABLE: 1591154 rows
[data-load] spreading stores with sales across two states, so row filters have an effect
UPDATE: 12 rows
[data-load] TX stores: 2,7,10
UPDATE: 3 rows
[data-load] verify: row counts in Polaris
"customer","100000"
"date_dim","1096"
"item","18000"
"store","12"
"store_sales","1591154"
[data-load] verify: stores by state, with sales, so both states return rows
"TN","9","796806"
"TX","3","794348"
[data-load] verify: no orphan foreign keys
"0","0"
Expected load output, running it again
[data-load] creating schema polaris.osi_demo
CREATE SCHEMA
[data-load] loading date_dim (2000-2002)
CREATE TABLE: 0 rows
[data-load] loading item
CREATE TABLE: 0 rows
[data-load] loading customer
CREATE TABLE: 0 rows
[data-load] loading store
CREATE TABLE: 0 rows
[data-load] loading store_sales (this is the big one, a few minutes)
CREATE TABLE: 0 rows
[data-load] spreading stores with sales across two states, so row filters have an effect
UPDATE: 12 rows
[data-load] TX stores: 2,7,10
UPDATE: 3 rows
[data-load] verify: row counts in Polaris
"customer","100000"
"date_dim","1096"
"item","18000"
"store","12"
"store_sales","1591154"
[data-load] verify: stores by state, with sales, so both states return rows
"TN","9","796806"
"TX","3","794348"
[data-load] verify: no orphan foreign keys
"0","0"

CREATE TABLE: 0 rows is success, not failure. Every create is IF NOT EXISTS, so a table that already exists is left alone and reports zero rows written. The counts underneath are read back from Polaris and are the real numbers, which is why they match the first run.

Three things have to hold before the rest of the walkthrough works. Check them yourself rather than trusting the script.

1. Verify the row counts.

Terminal window
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- trino --execute "
SELECT 'store_sales' AS table_name, count(*) AS row_count FROM polaris.osi_demo.store_sales
UNION ALL SELECT 'customer', count(*) FROM polaris.osi_demo.customer
UNION ALL SELECT 'item', count(*) FROM polaris.osi_demo.item
UNION ALL SELECT 'date_dim', count(*) FROM polaris.osi_demo.date_dim
UNION ALL SELECT 'store', count(*) FROM polaris.osi_demo.store
ORDER BY 1" 2>/dev/null
"customer","100000"
"date_dim","1096"
"item","18000"
"store","12"
"store_sales","1591154"

A missing table or a zero count means the load did not finish. Re-run data-load.sh, which only builds what is absent.

2. Verify the joins and state data.

Terminal window
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- trino --execute "
SELECT s.s_state,
count(DISTINCT s.s_store_sk) AS stores_with_sales,
count(*) AS sales_rows,
round(sum(ss.ss_ext_sales_price), 2) AS revenue
FROM polaris.osi_demo.store_sales ss
JOIN polaris.osi_demo.store s ON s.s_store_sk = ss.ss_store_sk
JOIN polaris.osi_demo.date_dim d ON d.d_date_sk = ss.ss_sold_date_sk
JOIN polaris.osi_demo.item i ON i.i_item_sk = ss.ss_item_sk
JOIN polaris.osi_demo.customer c ON c.c_customer_sk = ss.ss_customer_sk
GROUP BY s.s_state ORDER BY s.s_state" 2>/dev/null
"TN","3","796806","1516666718.80"
"TX","3","794348","1510391760.72"

You need two rows, both with non-zero revenue. One row means the row filter demo in Stage 6 will return nothing. Re-run data-load.sh, which resets the store states on every run.

stores_with_sales is 3 for each because an inner join only sees stores that have rows. Twelve stores exist and six carry sales. That is how tpcds generates the data.

3. Verify the Iceberg files in S3.

Terminal window
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- trino --execute "
SELECT * FROM polaris.osi_demo.\"store_sales\$properties\"" 2>/dev/null
"format","iceberg/PARQUET"
"provider","iceberg"
"format-version","2"
"location","s3://<your-polaris-bucket>/demo/osi_demo/store_sales-<uuid>"

Take that location and look at it directly. An Iceberg table is a data/ directory of Parquet plus a metadata/ directory holding the table metadata, the manifests, and the snapshot log.

Terminal window
aws s3 ls s3://<your-polaris-bucket>/demo/osi_demo/store_sales-<uuid>/metadata/
00000-....metadata.json the table metadata
....-m0.avro a manifest, listing data files
snap-....avro a snapshot, the atomic commit

An empty listing means Trino’s Pod Identity role cannot write to the bucket.

This deploys the two halves of the system. The manager watches SemanticModel resources, validates them against the live engine, and publishes compiled artifacts. The server loads those artifacts and answers queries.

The chart defaults to the published images, so no image flags are needed. If your cluster has Valkey, turn on result caching while you are here. A secretKeyRef can only read a Secret in its own namespace, so copy the password across first.

Terminal window
kubectl create namespace semantic-system --dry-run=client -o yaml | kubectl apply -f -
kubectl -n semantic-system create secret generic valkey-auth \
--from-literal=password="$(kubectl -n valkey get secret valkey-auth -o jsonpath='{.data.default}' | base64 -d)"

Then install.

Terminal window
helm upgrade --install semantic-operator charts/semantic-operator \
--namespace semantic-system --create-namespace \
--set server.auth.allowInsecureHeaderAuth=true \
--set engine.type=trino \
--set engine.host=trino.trino.svc.cluster.local \
--set valkey.addr=valkey.valkey.svc.cluster.local:6379 \
--set valkey.passwordSecret.name=valkey-auth \
--set valkey.passwordSecret.key=password

Without a Valkey, drop the three valkey.* flags. Everything still works, the server just reports cachedResult: false on every request.

engine.port is not set because the Trino client already defaults to 8080.

Expected install output
Release "semantic-operator" has been upgraded. Happy Helming!
NAME: semantic-operator
LAST DEPLOYED: Thu Jul 30 13:06:45 2026
NAMESPACE: semantic-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
semantic-operator installed in namespace semantic-system.
Endpoints (in-cluster):
MCP : http://semantic-operator-server.semantic-system.svc:8090/mcp
REST : http://semantic-operator-server.semantic-system.svc:8090/v1/models
Next steps:
1. Apply a model. Pick the one matching the engine you installed:
examples/retail/model/semanticmodel.yaml (Glue + StarRocks)
examples/stacks/eks/glue-trino/semanticmodel.yaml (Glue + Trino)
examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml (Polaris + Trino)
kubectl apply -f <model>.yaml
2. kubectl get semanticmodels -n semantic-system -w
Wait for VALIDATED=True PUBLISHED=True DRIFT=False.
3. Query it. Discovery is governed, so send a role:
kubectl port-forward -n semantic-system svc/semantic-operator-server 8090:8090
curl -s localhost:8090/v1/models -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' | jq

STATUS: deployed is the line that matters. On a first install it says has been installed with REVISION: 1. Re-running says has been upgraded and the revision climbs, which is normal.

Verify:

Terminal window
kubectl -n semantic-system get pods
pkill -f 'port-forward.*8090:8090' 2>/dev/null
kubectl -n semantic-system port-forward svc/semantic-operator-server 8090:8090 &
curl -s -o /dev/null -w '%{http_code}\n' localhost:8090/readyz
Expected verification output
NAME READY STATUS RESTARTS AGE
semantic-operator-manager-78494d67cd-jbtrf 1/1 Running 0 45s
semantic-operator-server-845f47764d-mnrc7 1/1 Running 0 45s
semantic-operator-server-845f47764d-ppq9b 1/1 Running 0 45s
200

The 200 from /readyz means the server has synced the model store and successfully pinged Trino. Check the operator logs if you see anything other than 200.

If you set the Valkey flags, confirm they landed.

Terminal window
kubectl -n semantic-system get deploy semantic-operator-server -o json \
| jq -r '.spec.template.spec.containers[0].env[] | select(.name|startswith("VALKEY")) | .name'
VALKEY_ADDR
VALKEY_DB
VALKEY_PASSWORD

Stage 1 gave you tables. Tables tell you a column is called ss_ext_sales_price and holds a decimal. They do not tell you it is what the business calls revenue, or that c_email_address is personal data.

That knowledge lives in a metadata catalog, curated by people. This stage puts it into DataHub so Stage 4 can pull descriptions, synonyms, and the PII tag into the model.

First, register the Polaris tables in DataHub. The script creates a DataHub ingestion job that reads the osi_demo schema through Trino and imports its datasets, columns, and types.

Terminal window
bash examples/stacks/eks/datahub-polaris-trino/datahub-ingest.sh
kubectl -n datahub logs job/datahub-ingest-polaris --tail=20

Then add the business meaning. In a real organisation a data steward builds this up over weeks. The annotation script creates a small glossary, documents selected datasets and fields, and marks the customer email field as PII, so the demo is reproducible.

Terminal window
GMS=$(kubectl -n datahub get svc -o name | grep -m1 datahub-gms | cut -d/ -f2)
pkill -f 'port-forward.*8091:8080' 2>/dev/null
kubectl -n datahub port-forward "svc/$GMS" 8091:8080 &
bash examples/stacks/eks/datahub-polaris-trino/datahub-annotate.sh
Terminal window
FE=$(kubectl -n datahub get svc -o name | grep -m1 datahub-frontend | cut -d/ -f2)
pkill -f 'port-forward.*9002:9002' 2>/dev/null
kubectl -n datahub port-forward "svc/$FE" 9002:9002 &

Open http://localhost:9002 and sign in with the credentials your DataHub install was configured with.

Search for osi_demo and open customer on the Trino platform. Look for three things, because these are exactly what Stage 4 imports into the model.

In the UI Becomes in the model
The dataset’s glossary term, Buyer ai_context.synonyms, how an agent grounds a phrase
Column descriptions Field documentation
The PII tag on c_email_address governance.denyFields, a 403 before any SQL runs

Search results also show datasets on other platforms if something else has ingested into this DataHub. The ones this walkthrough created are on the Trino platform, named polaris.osi_demo.*.

Verify without the UI if you prefer, straight from GMS:

Terminal window
SECRET=$(kubectl -n datahub get secret datahub-auth-secrets -o jsonpath='{.data.system_client_secret}' | base64 -d)
URN='urn:li:dataset:(urn:li:dataPlatform:trino,polaris.osi_demo.customer,PROD)'
curl -s -X POST localhost:8091/api/graphql \
-H 'Content-Type: application/json' -H "Authorization: Basic __datahub_system:${SECRET}" \
-d "{\"query\":\"{dataset(urn:\\\"$URN\\\"){glossaryTerms{terms{term{urn}}} editableSchemaMetadata{editableSchemaFieldInfo{fieldPath globalTags{tags{tag{urn}}}}}}}\"}" | jq

The PII tag sits on the column, not the dataset, so a dataset-level tag count of zero is normal.

Expected metadata
glossary term : urn:li:glossaryTerm:Buyer
column : c_email_address
tag = urn:li:tag:PII
desc = Customer contact email.

Confirm the descriptions, glossary terms, and PII tag are present before continuing. Stage 4 imports exactly what is there.

This is where the operator’s tooling starts doing the work. A semantic model has two halves. The physical half says which tables and columns exist. The business half says what the numbers mean and who may see them.

ossiectl derive writes the physical half for you by reading the engine. It cannot write the business half, because no machine knows what your company means by revenue. Run it twice, once without DataHub and once with, and the difference shows exactly which parts a catalog can fill in and which parts stay human work.

Output goes to tmp/, which is gitignored.

Terminal window
pkill -f 'port-forward.*8081:8080' 2>/dev/null
kubectl -n trino port-forward svc/trino 8081:8080 &
export SQL_DIALECT=trino ENGINE_HOST=127.0.0.1 ENGINE_PORT=8081
mkdir -p tmp
go run ./cmd/ossiectl derive -source engine -catalog polaris -database osi_demo \
-model tpcds_retail_model -name tpcds-retail -out tmp/scaffold-plain.yaml

The command reads Trino’s information_schema, so it sees exactly what the engine sees. Every dataset and column is filled in, and candidate joins are inferred from key naming.

What is missing is meaning. Open the file and look at the governance block:

# denyFields: [] # e.g. ["customer.c_email_address"]

That commented-out placeholder is the honest result. The tool knows c_email_address is a varchar. It has no way to know it holds personal data, so it refuses to guess and leaves the decision to you. On a real schema with hundreds of columns, somebody has to remember every sensitive one. That is the gap the next pass closes.

Same command, plus the enrichment flags. This pass needs the DataHub port-forward on 8091. The export below does not, because it reads the Secret through the Kubernetes API rather than over the forward, but the derive call does.

Terminal window
GMS=$(kubectl -n datahub get svc -o name | grep -m1 datahub-gms | cut -d/ -f2)
pkill -f 'port-forward.*8091:8080' 2>/dev/null
kubectl -n datahub port-forward "svc/$GMS" 8091:8080 &
sleep 3
export DATAHUB_TOKEN="Basic __datahub_system:$(kubectl -n datahub get secret \
datahub-auth-secrets -o jsonpath='{.data.system_client_secret}' | base64 -d)"
go run ./cmd/ossiectl derive -source engine -catalog polaris -database osi_demo \
-enrich datahub -datahub-url http://localhost:8091 \
-datahub-platform trino -datahub-dataset-prefix polaris \
-model tpcds_retail_model -name tpcds-retail -out tmp/scaffold-enriched.yaml
enriched 5/5 tables from DataHub (1 sensitive columns, 0 deprecated)

If DataHub is unreachable the command fails and writes nothing, rather than quietly producing a model that looks enriched and is not.

Terminal window
for f in tmp/scaffold-plain.yaml tmp/scaffold-enriched.yaml; do
printf '%-28s fields=%s descriptions=%s synonyms=%s\n' "$(basename $f)" \
"$(grep -c '^ - name:' $f)" "$(grep -c 'description:' $f)" "$(grep -c 'synonyms:' $f)"
done
diff tmp/scaffold-plain.yaml tmp/scaffold-enriched.yaml | grep '^>' | head -20
Expected difference
scaffold-plain.yaml fields=121 descriptions=7 synonyms=6
scaffold-enriched.yaml fields=121 descriptions=12 synonyms=9
+ synonyms: ["Buyer"]
+ description: "Customer contact email."
+ description: "Merchandise category."
+ synonyms: ["Merchandise"]
+ description: "Headcount at the store. Do not sum across a sales join."
+ synonyms: ["Storefront"]
+ description: "Extended sales price for the line item, net of discounts."
+ synonyms: ["Revenue"]
+ description: "Net profit for the line item."
+ denyFields:
+ - "customer.c_email_address"

The physical half is identical, 121 fields either way. Both files describe the same tables. What changed is that one of them now carries what people know. Three of those additions matter more than the rest.

synonyms: ["Revenue"] on ss_ext_sales_price. That table has seven plausible money columns: ss_ext_list_price, ss_ext_sales_price, ss_list_price, ss_net_paid, ss_net_paid_inc_tax, ss_net_profit, ss_sales_price. An agent writing SQL picks one and cannot tell you why. The glossary term records which one the business means.

“Do not sum across a sales join” on s_number_employees. That is the fan-out trap from the top of this page, written down. A steward wrote that sentence once and now every consumer of the model sees it.

denyFields is real, not a comment. The steward’s PII tag became an access rule the compiler enforces. In Stage 6 a request for that column returns 403 before any SQL exists.

Neither scaffold contains a single metric. Apply either one and it will validate, publish, and report healthy, but it cannot answer a metric request because total_sales and the other business calculations have not been defined.

A certified model is the scaffold after a person has finished it. Certified means somebody with the authority to define these terms has written down what each metric computes, which joins are real, and who may read what, and has put their name to it in review. From then on every agent, dashboard, and application that asks for total_sales gets that one definition.

Semantic Operator does not ask an LLM to turn that name into SQL. Its Go planner reads the metric expression, fields, relationships, and primary keys from the compiled model. It applies governance, builds the required joins and aggregations, and emits SQL for Trino.

This repo already contains the certified version of this model, semanticmodel.yaml, so the walkthrough does not stall while you hand write seven metrics.

Here is what a person added on top of your scaffold:

your scaffold the certified model who decided
datasets 5 5 machine
fields 121 34 a person, by pruning
field descriptions 5 34 a person, 5 came from DataHub
field synonyms 3 20 a person, 3 came from DataHub
primary keys 0 5 a person
relationships 0 4 a person, from the machine’s candidates
metrics 0 7 a person
views 0 5 a person
roles 1 3 a person
denyFields 1 2 1 from DataHub, 1 added
rowFilters 0 1 a person
TODO markers 23 0

Two rows are worth pausing on.

Primary keys go from 0 to 5. That is what makes a ratio metric safe across a join, and it is how the certified store_productivity avoids counting each store’s employee headcount once per sale.

Fields go down, from 121 to 34. A semantic model is meant to be smaller than the schema. Deciding that 87 columns should not be offered to an agent is authoring work just as much as writing a metric is.

Look at the difference yourself:

Terminal window
diff tmp/scaffold-enriched.yaml examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml | head -40
# the machine left the business half open
grep -c 'TODO' tmp/scaffold-enriched.yaml
# none left in the certified model
grep -c 'TODO' examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml
# what the person wrote: metrics and access policy
grep -A12 'governance:' examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml | head -14

Verify: all three validate offline, no cluster needed.

Terminal window
go run ./cmd/ossiectl validate -f tmp/scaffold-plain.yaml
go run ./cmd/ossiectl validate -f tmp/scaffold-enriched.yaml
go run ./cmd/ossiectl validate -f examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml

Applying the model is what makes it live. The operator does not take the file on trust. It introspects every table and column through Trino first and refuses to publish anything that does not match the engine. That check is the difference between a broken model failing here, visibly, and failing later inside somebody’s dashboard.

Apply the certified model from the repo rather than the scaffold you generated, because the scaffold has no metrics and would serve an empty model.

Terminal window
kubectl apply -f examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml
# wait for VALIDATED=True PUBLISHED=True DRIFT=False
kubectl -n semantic-system get semanticmodels -w

Not every tool speaks REST or MCP. Most BI tools only know how to read a table. So the operator writes ordinary SQL views for them, using the same compiler that answers API calls.

The model asks for them. Near the bottom of semanticmodel.yaml:

views:
- name: store_productivity_by_state
metrics: [store_productivity]
dimensions: [store.s_state]
role: admin

Five of those are defined, and the top of the file says where they go:

connection:
viewDatabase: polaris.semantic_views

When you applied the model, the manager compiled each entry into a CREATE OR REPLACE VIEW and ran it against Trino. role: admin means the view is built with that role’s permissions baked in, so a view of restricted data carries the restriction in its own WHERE clause.

They are replaced, not appended, on every publish. Change a metric and the view follows on the next reconcile. See them:

Terminal window
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- \
trino --execute "SHOW TABLES FROM polaris.semantic_views" 2>/dev/null
"clv_by_year"
"monthly_sales"
"sales_by_brand_year"
"sales_by_category_year"
"store_productivity_by_state"

You query one of them in Stage 6.

Verify what the operator did:

Terminal window
kubectl -n semantic-system describe semanticmodel tpcds-retail | grep -A8 Conditions
kubectl -n semantic-system get semanticmodel tpcds-retail \
-o jsonpath='{range .status.bindings[*]}{.dataset}{" -> "}{.table}{"\n"}{end}'
kubectl -n semantic-system get cm sm-tpcds-retail-compiled -o jsonpath='{.metadata.labels}'
Expected output
NAME VERSION VALIDATED PUBLISHED DRIFT AGE
tpcds-retail 4ee25537eec3 True True False 30s

Bindings show the model resolved through Polaris, and each was drift-checked against the live engine:

store_sales -> polaris.osi_demo.store_sales
date_dim -> polaris.osi_demo.date_dim
customer -> polaris.osi_demo.customer
item -> polaris.osi_demo.item
store -> polaris.osi_demo.store

And the published artifact carries its version:

{"app.kubernetes.io/managed-by":"semantic-operator",
"semantic.ossie.io/model":"tpcds_retail_model",
"semantic.ossie.io/version":"4ee25537eec3"}

DRIFT=False is the interesting one. Had a column been dropped from the table, the operator would have refused to publish and left the previous version serving.

Everything below talks to the semantic server on 8090. Stage 2 started that forward, but start it again if you are in a new terminal.

Terminal window
pkill -f 'port-forward.*8090:8090' 2>/dev/null
kubectl -n semantic-system port-forward svc/semantic-operator-server 8090:8090 &
sleep 3

Discovery is authenticated and governed, so pass a role here too.

Terminal window
curl -s localhost:8090/v1/models -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' | jq
curl -s localhost:8090/v1/models/tpcds_retail_model/metrics \
-H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' | jq -r '.metrics[].name'
curl -s -X POST localhost:8090/v1/models/tpcds_retail_model/query \
-H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' -H 'Content-Type: application/json' \
-d '{"metrics":["store_productivity"],"dimensions":["store.s_state"]}' \
| jq '{rows, requestHash, cachedResult}'
# run it twice
Expected output

Seven certified metric names:

total_sales
total_profit
total_quantity
transaction_count
customer_lifetime_value
store_productivity
sales_by_brand

Then the metric, twice:

{"rows":[["TN","644567.241309"],["TX","1826350.375719"]],
"requestHash":"66855481b292e9423ed73e2abccb151e","cachedResult":false}
{"rows":[["TN","644567.241309"],["TX","1826350.375719"]],
"requestHash":"66855481b292e9423ed73e2abccb151e","cachedResult":true}

The numbers depend on the scale factor you loaded, so yours may differ. What must hold is that the hash is identical across both runs.

cachedResult only flips to true if you installed with the Valkey flags in Stage 2. Without a cache it stays false and the answer is still correct.

This is the fan-out fix from the top of the page, working. store_productivity is total sales revenue divided by the employee headcount for stores in each state. Through the certified model it returns 644567.24 for TN and 1826350.38 for TX. The compiler knows that store has a primary key, so it plans the two sides of the ratio separately and counts each store’s headcount once rather than once per sale.

The requestHash is the other half. The same request, model version, and identity compile to the same SQL and the same hash from any client. That is what makes an answer auditable. You can point at a number in a report and find the exact request that produced it.

Terminal window
# column policy: analyst may not read PII
curl -s -X POST localhost:8090/v1/models/tpcds_retail_model/query \
-H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' -H 'Content-Type: application/json' \
-d '{"metrics":["total_sales"],"dimensions":["customer.c_email_address"]}' | jq
# row policy: tx_analyst is restricted to Texas
curl -s -X POST localhost:8090/v1/models/tpcds_retail_model/query \
-H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: tx_analyst' -H 'Content-Type: application/json' \
-d '{"metrics":["total_sales"],"dimensions":["store.s_state"]}' | jq '.rows'
# the exact SQL, without executing it
curl -s -X POST localhost:8090/v1/models/tpcds_retail_model/sql \
-H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: tx_analyst' -H 'Content-Type: application/json' \
-d '{"metrics":["total_sales"],"dimensions":["item.i_category"]}' | jq -r '.plan.sql'
Expected output

The PII request is refused:

{"error":"unauthorized: role \"analyst\" may not read field \"customer.c_email_address\""}

tx_analyst sees one state, analyst sees both. Same request, same model:

[["TX","1510391760.72"]]
[["TN","1516666718.80"],["TX","1510391760.72"]]

And the compiled SQL carries the row filter:

/* semantic-layer model=tpcds_retail_model version=4ee25537eec3 request=4cfc29f3... */
SELECT "item"."i_category" AS "item.i_category",
SUM("store_sales"."ss_ext_sales_price") AS "total_sales"
FROM "polaris"."osi_demo"."store_sales" AS "store_sales"
INNER JOIN "polaris"."osi_demo"."item" AS "item"
ON "store_sales"."ss_item_sk" = "item"."i_item_sk"
INNER JOIN "polaris"."osi_demo"."store" AS "store"
ON "store_sales"."ss_store_sk" = "store"."s_store_sk"
WHERE ((("store"."s_state" = 'TX')))
GROUP BY 1
ORDER BY 1
LIMIT 1000

The LIMIT is the server’s default row limit, applied to any request that does not set one of its own.

Two things happened there that are easy to skim past.

The 403 came back before any SQL existed. The compiler refused at planning time, so the email column was never named in a query, never sent to Trino, and never appeared in an engine log. Compare that with filtering results after the fact, where the data has already left the database.

The row filter is inside the WHERE clause. It is not applied to the response afterwards, and there is no code path where forgetting to apply it is possible. tx_analyst cannot see Tennessee because the SQL that would return it is never generated.

The TN total, 1516666718.80, is the same number the hand written SQL returned in Stage 1. The governed path and a direct query agree.

The Trino web UI shows every governed query as the engine saw it, tagged with the model and request identifiers from the SQL comment.

Terminal window
pkill -f 'port-forward.*8081:8080' 2>/dev/null
kubectl -n trino port-forward svc/trino 8081:8080 &

Open http://localhost:8081/ui and sign in with your Trino credentials.

Now query one of the views the operator created in Stage 5. It is an ordinary Trino view, so a BI tool reads it knowing nothing about the semantic layer:

Terminal window
POD=$(kubectl -n trino get pods -o name | grep coordinator | head -1 | cut -d/ -f2)
kubectl -n trino exec "$POD" -c trino-coordinator -- \
trino --execute "SELECT * FROM polaris.semantic_views.store_productivity_by_state ORDER BY 1"
"TN","644567.241309"
"TX","1826350.375719"

The same numbers the API returned, from the same compiled definition.

AI agents use the same server over MCP at /mcp (tools: list_models, list_metrics, list_dimensions, query_metric). The agent selects certified metrics by name and never writes SQL. The agent example drives exactly this.

The question that opened this page now has a precise, governed definition. store_productivity returns 644567.24 for TN and 1826350.38 for TX, and you can inspect the SQL that produced both values.

What you saw
A tool wrote the boring half derive filled 5 datasets and 121 columns straight from the engine
A catalog supplied the meaning The enriched scaffold gained synonyms, descriptions, and a working denyFields
People wrote the rest 7 metrics, 5 primary keys, and 4 joins no tool could have chosen
Kubernetes checked it first Drift checked against Trino before anything served
Same governed input, same answer Identical requestHash for the same model version, request, and identity
Rules that cannot be skipped 403 before any SQL existed, and a WHERE clause the caller cannot remove
One model, every consumer Same numbers over REST, over MCP, and from a plain SQL view

Next: run the same model on Glue and Trino or Glue and StarRocks and watch the numbers match, or write your own with the authoring guide.

At the default sf1 scale factor:

Check Value
store_productivity by state TN 644567.241309, TX 1826350.375719
total_sales as tx_analyst TX 1510391760.72, one row
total_sales as analyst TN 1516666718.80, TX 1510391760.72

Remove what this walkthrough created, in reverse order. This leaves your cluster, Trino, Polaris, and DataHub untouched.

Terminal window
# stop every port-forward
pkill -f 'kubectl.*port-forward'
# the model and the operator
kubectl delete -f examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml
helm uninstall semantic-operator -n semantic-system
kubectl delete namespace semantic-system
# the governed views the operator created in Trino
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- \
trino --execute "DROP SCHEMA IF EXISTS polaris.semantic_views CASCADE" 2>/dev/null
# the demo tables and their Iceberg files
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- \
trino --execute "DROP SCHEMA IF EXISTS polaris.osi_demo CASCADE" 2>/dev/null
# local scaffolds
rm -rf tmp/

If you deployed semantic-on-eks for this walkthrough, run its cleanup.sh script from the data-stacks/semantic-on-eks directory:

Terminal window
./cleanup.sh

This removes the EKS cluster and the AWS resources created by deploy.sh.